's'
This commit is contained in:
@@ -13,6 +13,10 @@ PAYMENT_API_URL=http://127.0.0.1:8007
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:8007
|
||||
# 生产同机部署时取消注释(Next.js 与 payjsapi 同服务器)
|
||||
# PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007
|
||||
# 内网/慢网络可适当提高超时(毫秒):check_vip 默认 15s,payh5/redirect 默认 60s
|
||||
# PAYJSAPI_CHECK_TIMEOUT=15000
|
||||
# PAYJSAPI_PAYH5_TIMEOUT=60000
|
||||
# PAYJSAPI_REDIRECT_TIMEOUT=60000
|
||||
PAYMENT_PROVIDER=zpay
|
||||
PAYMENT_DEFAULT_AMOUNT=100
|
||||
NEXT_PUBLIC_SITE_ID=vip
|
||||
|
||||
@@ -90,9 +90,10 @@ async function createPayOrder(
|
||||
...(provider && { provider }),
|
||||
};
|
||||
const payh5Url = `${apiUrl}/nomadvip/payh5`;
|
||||
console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url);
|
||||
const payh5Timeout = Number(process.env.PAYJSAPI_PAYH5_TIMEOUT) || 60000;
|
||||
console.log("[pay] createPayOrder 请求 payjsapi url=%s timeout=%ds", payh5Url, payh5Timeout / 1000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const timeout = setTimeout(() => controller.abort(), payh5Timeout);
|
||||
const res = await fetch(payh5Url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -100,14 +101,18 @@ async function createPayOrder(
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status);
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
throw new Error(data?.detail || data?.msg || "支付创建失败");
|
||||
const statusOk = data?.status === "ok" || data?.status === "success" || (res.ok && (data?.xorpay_params || data?.params));
|
||||
console.log("[pay] createPayOrder 响应 http=%s status=%s hasParams=%s", res.status, data?.status, !!(data?.xorpay_params || data?.params));
|
||||
if (!res.ok || !statusOk) {
|
||||
const errMsg = data?.detail || data?.msg || data?.error || "支付创建失败";
|
||||
console.error("[pay] createPayOrder 失败:", errMsg, "body=", JSON.stringify(data).slice(0, 300));
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
// xorpay: xorpay_params + xorpay_url | zpay: params + pay_url
|
||||
const params = (data.xorpay_params ?? data.params) || {};
|
||||
const payUrl = data.xorpay_url || data.pay_url || "";
|
||||
if (!params || typeof params !== "object") {
|
||||
if (!params || typeof params !== "object" || Object.keys(params).length === 0) {
|
||||
console.error("[pay] createPayOrder 参数为空 data.keys=", Object.keys(data || {}));
|
||||
throw new Error("payjsapi 未返回支付参数");
|
||||
}
|
||||
return {
|
||||
@@ -149,7 +154,9 @@ 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 reqProvider = String(body?.provider || "").trim().toLowerCase() || undefined;
|
||||
// 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay
|
||||
const effectiveProvider =
|
||||
device === "wechat" ? "xorpay" : "zpay";
|
||||
const accept = request.headers.get("accept") || "";
|
||||
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
|
||||
const preferJson = String(body?.prefer_json) === "1";
|
||||
@@ -175,139 +182,94 @@ export async function POST(request: NextRequest) {
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
|
||||
const { params, payUrl, provider } = await createPayOrder(
|
||||
apiUrl,
|
||||
user_id,
|
||||
finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
clientIp,
|
||||
reqProvider
|
||||
);
|
||||
// zpay:直接走 redirect,不先调 payh5(避免 payh5 创建 zpay 订单时阻塞超时)
|
||||
// payjsapi 的 payh5/redirect 可独立调用,内部自建订单。digital/cnomadcna 仍用 payh5+redirect 流程,路由隔离互不影响。
|
||||
if (effectiveProvider === "zpay") {
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
site_id: "vip",
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
provider: "zpay",
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
const redirectUrl = `${apiUrl}/nomadvip/payh5/redirect`;
|
||||
const redirectTimeout = Number(process.env.PAYJSAPI_REDIRECT_TIMEOUT) || 60000;
|
||||
console.log("[pay] zpay 直接 redirect url=%s timeout=%ds", redirectUrl, redirectTimeout / 1000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), redirectTimeout);
|
||||
const redirectRes = await fetch(redirectUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(redirectPayload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml("支付参数为空"), {
|
||||
status: 500,
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim() || "";
|
||||
if (!wantHtml || (preferJson && orderId)) {
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
order_id: orderId,
|
||||
redirect_url: location,
|
||||
provider: "zpay",
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
const res = NextResponse.redirect(location);
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const params = (resData.params ?? resData) as Record<string, string>;
|
||||
const orderId = String(params?.out_trade_no ?? resData?.order_id ?? "").trim();
|
||||
const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl;
|
||||
const html = buildPayHtml(submitUrl, params || {}, {
|
||||
directToCashier: device === "wechat",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
// wantHtml=0:返回 JSON,供微信 fetch 后直接提交表单(无中转页)
|
||||
if (!wantHtml) {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
params,
|
||||
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
|
||||
provider,
|
||||
order_id: orderId,
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// xorpay:直接 payh5 返回收银台参数,无需 redirect 中转
|
||||
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
|
||||
if (provider === "xorpay") {
|
||||
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",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// zpay:需要 redirect 获取 device 相关跳转
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
site_id: "vip",
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
provider: "zpay",
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
const redirectRes = await fetch(`${apiUrl}/nomadvip/payh5/redirect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(redirectPayload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
if (preferJson && orderId) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
order_id: orderId,
|
||||
redirect_url: location,
|
||||
provider: "zpay",
|
||||
});
|
||||
}
|
||||
const res = NextResponse.redirect(location);
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const orderId = String(params?.out_trade_no || "").trim();
|
||||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||||
directToCashier: device === "wechat",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const orderId = String(resData.order_id || "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ch = String(resData.channel || "wxpay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const html = `<!DOCTYPE html>
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const orderId = String(resData.order_id || "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ch = String(resData.channel || "wxpay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
@@ -433,6 +395,80 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
const errMsg = resData?.detail || resData?.msg || "zpay redirect 失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: redirectRes.status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
|
||||
}
|
||||
|
||||
const errMsg = resData?.detail || resData?.msg || "zpay 未返回有效响应";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
// xorpay:调用 payh5 获取收银台参数
|
||||
const { params, payUrl, provider } = await createPayOrder(
|
||||
apiUrl,
|
||||
user_id,
|
||||
finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
clientIp,
|
||||
effectiveProvider
|
||||
);
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml("支付参数为空"), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
// wantHtml=0:返回 JSON,供微信 fetch 后直接提交表单(无中转页)
|
||||
if (!wantHtml) {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
params,
|
||||
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
|
||||
provider,
|
||||
order_id: orderId,
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// xorpay:直接 payh5 返回收银台参数,无需 redirect 中转
|
||||
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
|
||||
if (provider === "xorpay") {
|
||||
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",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
@@ -442,34 +478,17 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
const errMsg = resData?.detail || resData?.msg || "支付跳转失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: redirectRes.status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
|
||||
}
|
||||
|
||||
const createFailMsg = "支付创建失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(createFailMsg), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: createFailMsg }, { status: 500 });
|
||||
return NextResponse.json({ ok: false, error: "支付渠道异常" }, { status: 500 });
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const msg = err.message || "";
|
||||
const payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py,端口 8007)";
|
||||
const payjsapiHint =
|
||||
"请确认 payjsapi 已启动且 nomadvip 能访问。同机部署可设 PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
? payjsapiHint
|
||||
: err.name === "AbortError"
|
||||
? `请求超时,${payjsapiHint}`
|
||||
? `请求超时(30s),${payjsapiHint}`
|
||||
: msg;
|
||||
console.error("Pay API error:", err);
|
||||
const errorMsg = `支付请求失败: ${hint}`;
|
||||
|
||||
@@ -34,9 +34,10 @@ async function checkVipFromPayjsapi(
|
||||
userId
|
||||
)}`;
|
||||
console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url);
|
||||
const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15000;
|
||||
const res = await fetch(
|
||||
url,
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(5000) }
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(checkTimeout) }
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.log(
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { isPaid } from "../lib/payment";
|
||||
|
||||
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
|
||||
|
||||
export function ChatFloatButton() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
const baseClass =
|
||||
"animate__animated animate__bounceIn fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110";
|
||||
const paidClass = "bg-[var(--accent)] text-[var(--accent-foreground)] shadow-[var(--accent)]/30 hover:shadow-xl hover:shadow-[var(--accent)]/40";
|
||||
const lockedClass = "bg-[var(--accent)] text-[var(--accent-foreground)] shadow-[var(--accent)]/30 hover:shadow-xl hover:shadow-[var(--accent)]/40";
|
||||
|
||||
if (paid) {
|
||||
return (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${baseClass} ${paidClass}`}
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
aria-label="即时问答"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="animate__animated animate__bounceIn fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent)] text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/30 transition-all hover:scale-110 hover:shadow-xl hover:shadow-[var(--accent)]/40"
|
||||
<Link
|
||||
href="/"
|
||||
className={`${baseClass} ${lockedClass}`}
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
aria-label="即时问答"
|
||||
aria-label="即时问答(开通VIP解锁)"
|
||||
title="开通VIP解锁即时问答"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { SITE_CONFIG } from "../config/course";
|
||||
import { isPaid } from "../lib/payment";
|
||||
|
||||
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
|
||||
|
||||
export function Footer() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="animate__animated animate__fadeInUp border-t border-[var(--border)] py-6 sm:py-8" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
@@ -16,17 +27,30 @@ export function Footer() {
|
||||
<Link href="/cloudphone" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
课程首页
|
||||
</Link>
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
{paid ? (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
title="开通VIP解锁即时问答"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
{SITE_CONFIG.footer}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { SITE_CONFIG } from "../config/course";
|
||||
import { isPaid } from "../lib/payment";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||
|
||||
export function Header() {
|
||||
@@ -23,35 +24,71 @@ export function Header() {
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
{paid ? (
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
title="开通VIP解锁即时问答"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
首页
|
||||
</Link>
|
||||
<Link
|
||||
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
|
||||
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{paid ? "进入课程 →" : "立即报名 →"}
|
||||
</Link>
|
||||
<Link
|
||||
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
|
||||
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
|
||||
aria-label={paid ? "进入课程" : "立即报名"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
{paid ? (
|
||||
<Link
|
||||
href="/cloudphone/course/0/0"
|
||||
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
进入课程 →
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
立即报名 →
|
||||
</button>
|
||||
)}
|
||||
{paid ? (
|
||||
<Link
|
||||
href="/cloudphone/course/0/0"
|
||||
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
|
||||
aria-label="进入课程"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
|
||||
aria-label="立即报名"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { CTA_CONFIG } from "../../config/course";
|
||||
import { isPaid } from "../../lib/payment";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
|
||||
export function CTASection() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
@@ -34,12 +35,22 @@ export function CTASection() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
|
||||
>
|
||||
{paid ? "开始学习 →" : "立即加入 →"}
|
||||
</Link>
|
||||
{paid ? (
|
||||
<Link
|
||||
href="/cloudphone/course/0/0"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
|
||||
>
|
||||
开始学习 →
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
|
||||
>
|
||||
立即加入 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { HERO_CONFIG, CURRICULUM_CONFIG } from "../../config/course";
|
||||
import { isPaid } from "../../lib/payment";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import { getLastWatched, getProgress } from "../../lib/learning";
|
||||
import { ParticleBackground } from "../ParticleBackground";
|
||||
|
||||
@@ -74,13 +75,21 @@ export function Hero() {
|
||||
>
|
||||
继续学习 →
|
||||
</Link>
|
||||
) : (
|
||||
) : paid ? (
|
||||
<Link
|
||||
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
|
||||
href="/cloudphone/course/0/0"
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
{paid ? "进入课程 →" : "立即报名 →"}
|
||||
进入课程 →
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
立即报名 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{paid && progress.completed > 0 && (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export const SITE_CONFIG = {
|
||||
brand: "🌍 云手机",
|
||||
ctaUrl: "/cloudphone/pay",
|
||||
ctaUrl: "/",
|
||||
footer: "🌍 云手机 · 低成本工作室搭建指南",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import { Header } from "../../../components";
|
||||
import { VideoPlayer } from "../../../components/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
@@ -77,7 +78,7 @@ export default function LessonPage() {
|
||||
prevLesson={prevLesson}
|
||||
nextLesson={nextLesson}
|
||||
courseHomeHref="/cloudphone"
|
||||
payHref="/cloudphone/pay"
|
||||
payHref="#"
|
||||
>
|
||||
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
|
||||
{unlocked ? (
|
||||
@@ -93,12 +94,13 @@ export default function LessonPage() {
|
||||
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
|
||||
<span className="text-6xl">🔒</span>
|
||||
<p className="text-lg text-white/90">报名解锁本章节</p>
|
||||
<Link
|
||||
href="/cloudphone/pay"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
立即报名
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* 支付状态 - 适配 nomadvip paidType(克隆自 nomadlms)
|
||||
*
|
||||
* CTA 校验逻辑:有本地 paidType=vip 时优先使用;无则点击「立即加入」直接进入支付流程
|
||||
*/
|
||||
|
||||
/** nomadvip 使用 paidType=vip 表示已支付 */
|
||||
|
||||
@@ -57,8 +57,8 @@ export const TOPICS_DISPLAY = [
|
||||
|
||||
/** 电子书展示 - 首页展示用 */
|
||||
export const EBOOKS_DISPLAY = [
|
||||
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook" },
|
||||
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone" },
|
||||
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook", free: true },
|
||||
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone", locked: true },
|
||||
] as const;
|
||||
|
||||
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
|
||||
@@ -70,8 +70,8 @@ export const VIDEO_COURSES_DISPLAY = [
|
||||
] as const;
|
||||
|
||||
export const COMPARE_ITEMS = [
|
||||
{ feature: "电子书", single: "单买", member: "全解锁" },
|
||||
{ feature: "视频教程", single: "单买", member: "全解锁" },
|
||||
{ feature: "电子书", single: "按需开通", member: "全解锁" },
|
||||
{ feature: "视频教程", single: "按需开通", member: "全解锁" },
|
||||
{ feature: "私密社群", single: "—", member: "✓" },
|
||||
{ feature: "即时问答", single: "—", member: "✓" },
|
||||
{ feature: "持续更新", single: "—", member: "✓" },
|
||||
|
||||
278
app/lib/triggerPay.ts
Normal file
278
app/lib/triggerPay.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 共享支付触发逻辑 - 与首页「立即加入」效果一致
|
||||
* 供首页、课程页、云手机页等所有「立即报名」按钮使用,直接发起支付,不跳转首页
|
||||
*
|
||||
* 校验逻辑:
|
||||
* - 页面有本地存储(登录资料 + paidType=vip)时,优先使用本地存储并立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
|
||||
* - 无身份标识和 VIP 权限等存储时,点击按钮直接进入支付流程,不校验本地 userId
|
||||
*/
|
||||
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
|
||||
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
|
||||
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
|
||||
const PAY_ORDER_MAX_AGE = 60 * 15;
|
||||
|
||||
function getStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function setStorage(key: string, value: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function setAnonymousUserCookie(userId: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (!/^user\d+$/.test(userId)) return;
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent(
|
||||
userId
|
||||
)}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
function setPayOrderCookie(orderId: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const normalizedOrderId = orderId.trim();
|
||||
if (!normalizedOrderId) return;
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent(
|
||||
`${normalizedOrderId}|${Date.now()}`
|
||||
)}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
function savePendingOrder(orderId: string): void {
|
||||
const normalizedOrderId = orderId.trim();
|
||||
if (!normalizedOrderId) return;
|
||||
setPayOrderCookie(normalizedOrderId);
|
||||
setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`);
|
||||
}
|
||||
|
||||
function getStoredAnonymousUserId(): string | null {
|
||||
const memosAccount = getStorage("memosAccount");
|
||||
if (memosAccount && /^user\d+$/.test(memosAccount)) return memosAccount;
|
||||
const userId = getStorage("userId");
|
||||
if (userId && /^user\d+$/.test(userId)) return userId;
|
||||
const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE);
|
||||
if (cookieUserId && /^user\d+$/.test(cookieUserId)) {
|
||||
setStorage("memosAccount", cookieUserId);
|
||||
return cookieUserId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 同步获取 userId,用于微信内立即表单提交,避免异步导致手势失效 */
|
||||
function getOrCreateUserIdSync(): string {
|
||||
const anonymousUserId = getStoredAnonymousUserId();
|
||||
if (anonymousUserId) {
|
||||
setStorage("userId", anonymousUserId);
|
||||
setAnonymousUserCookie(anonymousUserId);
|
||||
return anonymousUserId;
|
||||
}
|
||||
let userId = getStorage("userId");
|
||||
if (userId?.startsWith("{") && userId.includes("data")) {
|
||||
try {
|
||||
const parsed = JSON.parse(userId) as { data?: string };
|
||||
if (parsed?.data) {
|
||||
userId = parsed.data;
|
||||
setStorage("userId", userId);
|
||||
}
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
}
|
||||
if (!userId) {
|
||||
userId = `user${Date.now()}`;
|
||||
setStorage("userId", userId);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
async function getOrCreateUserId(): Promise<string> {
|
||||
const syncId = getOrCreateUserIdSync();
|
||||
if (syncId && !syncId.startsWith("user")) {
|
||||
return syncId;
|
||||
}
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user?.id) {
|
||||
setStorage("userId", meData.user.id);
|
||||
return meData.user.id;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return syncId;
|
||||
}
|
||||
|
||||
function redirectToPayZpay(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
device: "pc" | "h5" | "wechat";
|
||||
}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/pay";
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
["channel", params.channel],
|
||||
["device", params.device],
|
||||
["provider", "zpay"],
|
||||
["html", "1"],
|
||||
];
|
||||
fields.forEach(([k, v]) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = v;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
async function redirectToPayZpayH5(
|
||||
params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
},
|
||||
onPendingOrder?: (orderId: string) => void
|
||||
): Promise<string | null> {
|
||||
const res = await fetch("/api/pay", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
device: "h5",
|
||||
provider: "zpay",
|
||||
html: "0",
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.ok) {
|
||||
alert(data?.error || "支付发起失败");
|
||||
return null;
|
||||
}
|
||||
const hasRedirect = !!data?.redirect_url;
|
||||
const hasParams = !!data?.params && !!data?.payUrl;
|
||||
if (!hasRedirect && !hasParams) {
|
||||
alert(data?.error || "支付发起失败");
|
||||
return null;
|
||||
}
|
||||
const orderId = data?.order_id?.trim() || null;
|
||||
if (orderId) {
|
||||
savePendingOrder(orderId);
|
||||
onPendingOrder?.(orderId);
|
||||
}
|
||||
if (data?.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
return orderId;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = data.payUrl;
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
|
||||
if (v == null) continue;
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = String(v);
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
return orderId;
|
||||
}
|
||||
|
||||
/** 微信内:同步表单 POST 到 /api/pay,避免异步 fetch 后 form.submit 被微信拦截 */
|
||||
function payWechatXorpaySync(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/pay";
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
["channel", params.channel],
|
||||
["device", "wechat"],
|
||||
["provider", "xorpay"],
|
||||
["html", "1"],
|
||||
];
|
||||
fields.forEach(([k, v]) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = v;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发支付 - 与首页「立即加入」效果一致,直接发起支付流程
|
||||
* @param onPendingOrder 可选,H5 支付时收到 order_id 的回调(用于轮询)
|
||||
*/
|
||||
export async function triggerPay(
|
||||
onPendingOrder?: (orderId: string) => void
|
||||
): Promise<void> {
|
||||
const userId =
|
||||
typeof navigator !== "undefined" && /MicroMessenger/i.test(navigator.userAgent)
|
||||
? getOrCreateUserIdSync()
|
||||
: await getOrCreateUserId();
|
||||
if (/^user\d+$/.test(userId)) {
|
||||
setStorage("memosAccount", userId);
|
||||
setStorage("userId", userId);
|
||||
setAnonymousUserCookie(userId);
|
||||
}
|
||||
const returnUrl = `${window.location.origin}/paid`;
|
||||
const env = getPayEnv();
|
||||
const device = getDeviceFromEnv(env);
|
||||
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
|
||||
const base = {
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
total_fee: totalFee,
|
||||
type: "vip",
|
||||
channel: "wxpay" as const,
|
||||
};
|
||||
if (device === "wechat") {
|
||||
payWechatXorpaySync(base);
|
||||
} else if (device === "h5") {
|
||||
await redirectToPayZpayH5(base, onPendingOrder);
|
||||
} else {
|
||||
redirectToPayZpay({ ...base, device });
|
||||
}
|
||||
}
|
||||
260
app/page.tsx
260
app/page.tsx
@@ -1,150 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||||
import { VipLayout } from "@/app/vip/components/VipLayout";
|
||||
import { LandingPage } from "@/app/vip/components/LandingPage";
|
||||
import { DashboardPage } from "@/app/vip/components/DashboardPage";
|
||||
import styles from "@/app/vip/components/vip.module.css";
|
||||
|
||||
/** 微信内支付渠道:zpay | xorpay */
|
||||
const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay";
|
||||
// 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay
|
||||
const DEFAULT_EMAIL_PASSWORD = "12345678";
|
||||
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
|
||||
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
|
||||
const PAY_ORDER_MAX_AGE = 60 * 15;
|
||||
|
||||
function redirectToPayZpay(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
device: "pc" | "h5" | "wechat";
|
||||
}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/pay";
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
["channel", params.channel],
|
||||
["device", params.device],
|
||||
["provider", "zpay"],
|
||||
["html", "1"],
|
||||
];
|
||||
fields.forEach(([k, v]) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = v;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
async function redirectToPayZpayH5(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
}, onPendingOrder?: (orderId: string) => void): Promise<string | null> {
|
||||
const res = await fetch("/api/pay", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
device: "h5",
|
||||
provider: "zpay",
|
||||
html: "0",
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.ok || !data?.params || !data?.payUrl) {
|
||||
alert(data?.error || "支付发起失败");
|
||||
return null;
|
||||
}
|
||||
const orderId = data?.order_id?.trim() || null;
|
||||
if (orderId) {
|
||||
savePendingOrder(orderId);
|
||||
onPendingOrder?.(orderId);
|
||||
}
|
||||
if (data?.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
return orderId;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = data.payUrl;
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
|
||||
if (v == null) continue;
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = String(v);
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
return orderId;
|
||||
}
|
||||
|
||||
async function payWechatXorpay(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
channel: "alipay" | "wxpay";
|
||||
}) {
|
||||
const res = await fetch("/api/pay", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
total_fee: params.total_fee,
|
||||
device: "wechat",
|
||||
provider: "xorpay",
|
||||
html: "0",
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.ok || !data?.params || !data?.payUrl) {
|
||||
alert(data?.error || "支付发起失败");
|
||||
return;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = data.payUrl;
|
||||
const orderId = data?.order_id?.trim();
|
||||
if (orderId) {
|
||||
savePendingOrder(orderId);
|
||||
}
|
||||
form.target = "_self";
|
||||
form.style.display = "none";
|
||||
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
|
||||
if (v == null) continue;
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = String(v);
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function getStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
@@ -213,11 +84,11 @@ function getPendingOrderId(): string | null {
|
||||
return orderId || null;
|
||||
}
|
||||
|
||||
/** 首页永远不显示 loading,所有接口在后台静默执行 */
|
||||
export default function Home() {
|
||||
const [paidType, setPaidType] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
||||
|
||||
const savePaidType = useCallback((type: string | null) => {
|
||||
@@ -490,7 +361,6 @@ export default function Home() {
|
||||
clearPendingOrder();
|
||||
setPaidType(null);
|
||||
setUserName(null);
|
||||
setLoading(false);
|
||||
const path = window.location.pathname || "/";
|
||||
window.location.replace(path);
|
||||
} catch (error) {
|
||||
@@ -498,37 +368,9 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const doPay = useCallback(async () => {
|
||||
const userId = await getOrCreateUserId();
|
||||
if (/^user\d+$/.test(userId)) {
|
||||
setStorage("memosAccount", userId);
|
||||
setStorage("userId", userId);
|
||||
setAnonymousUserCookie(userId);
|
||||
}
|
||||
const returnUrl = `${window.location.origin}/paid`;
|
||||
const env = getPayEnv();
|
||||
const device = getDeviceFromEnv(env);
|
||||
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
|
||||
const base = {
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
total_fee: totalFee,
|
||||
type: "vip",
|
||||
channel: "wxpay" as const,
|
||||
};
|
||||
if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") {
|
||||
payWechatXorpay(base);
|
||||
} else if (device === "h5") {
|
||||
const orderId = await redirectToPayZpayH5(base, setPayPendingOrderId);
|
||||
if (orderId) setPayPendingOrderId(orderId);
|
||||
} else {
|
||||
redirectToPayZpay({ ...base, device });
|
||||
}
|
||||
}, [getOrCreateUserId]);
|
||||
|
||||
const handlePay = useCallback(() => {
|
||||
doPay();
|
||||
}, [doPay]);
|
||||
triggerPay(setPayPendingOrderId);
|
||||
}, []);
|
||||
|
||||
const handleVerifySuccess = useCallback(() => {
|
||||
window.location.reload();
|
||||
@@ -593,46 +435,56 @@ export default function Home() {
|
||||
const pendingOrderId = getPendingOrderId();
|
||||
if (pendingOrderId) {
|
||||
setPayPendingOrderId(pendingOrderId);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖
|
||||
// 本地已有 VIP 标识时优先使用,立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
|
||||
const localType = getStorage("paidType");
|
||||
const localUserName = getStorage("userName");
|
||||
const localUserEmail = getStorage("userEmail");
|
||||
const localUserId = getStorage("userId");
|
||||
if (localType === "vip" && (localUserName || localUserEmail) && localUserId) {
|
||||
const isAnonymous = /^user\d+$/.test(localUserId);
|
||||
let verified = false;
|
||||
if (isAnonymous) {
|
||||
verified = await checkPaymentFromPB(localUserId);
|
||||
} else {
|
||||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipData = await vipRes.json();
|
||||
verified = !!vipData?.vip;
|
||||
// 立即展示 VIP 状态,不阻塞首屏
|
||||
setPaidType(localType);
|
||||
setUserName(localUserEmail || localUserName || "");
|
||||
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
|
||||
if (!localUserEmail && !localUserName?.includes("@")) {
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user?.email) setUserEmail(meData.user.email);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (verified) {
|
||||
setPaidType(localType);
|
||||
setUserName(localUserEmail || localUserName || "");
|
||||
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
|
||||
if (!localUserEmail && !localUserName?.includes("@")) {
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user?.email) setUserEmail(meData.user.email);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态
|
||||
const verifyInBackground = async () => {
|
||||
const isAnonymous = /^user\d+$/.test(localUserId);
|
||||
let verified = false;
|
||||
try {
|
||||
if (isAnonymous) {
|
||||
verified = await checkPaymentFromPB(localUserId);
|
||||
} else {
|
||||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipData = await vipRes.json();
|
||||
verified = !!vipData?.vip;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!verified) {
|
||||
removeStorage("paidType");
|
||||
removeStorage("userName");
|
||||
removeStorage("userEmail");
|
||||
savePaidType(null);
|
||||
setUserName(null);
|
||||
setUserEmail(null);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
} else {
|
||||
removeStorage("paidType");
|
||||
removeStorage("userName");
|
||||
removeStorage("userEmail");
|
||||
}
|
||||
};
|
||||
void verifyInBackground();
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
|
||||
const anonymousCandidate = getStoredAnonymousUserId();
|
||||
const hadStoredUserId = !!anonymousCandidate;
|
||||
if (anonymousCandidate) {
|
||||
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
|
||||
if (recovered) {
|
||||
@@ -642,7 +494,6 @@ export default function Home() {
|
||||
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
|
||||
setUserName(name);
|
||||
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -656,7 +507,6 @@ export default function Home() {
|
||||
setPaidType(afterLocalType);
|
||||
setUserName(afterLocalUserEmail || afterLocalUserName || "");
|
||||
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -688,7 +538,6 @@ export default function Home() {
|
||||
}
|
||||
savePaidType("vip");
|
||||
saveUserName(meData.user.email || meData.user.id);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -696,15 +545,17 @@ export default function Home() {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
|
||||
if (userIdForCheck) {
|
||||
const found = await checkPaymentFromPB(userIdForCheck);
|
||||
if (found) {
|
||||
const name = getStorage("userEmail") || getStorage("userName");
|
||||
if (name) setUserName(name);
|
||||
// 仅当有本地存储的 userId 时才校验(无身份/VIP 时跳过,点击按钮直接进入支付流程)
|
||||
if (hadStoredUserId) {
|
||||
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
|
||||
if (userIdForCheck) {
|
||||
const found = await checkPaymentFromPB(userIdForCheck);
|
||||
if (found) {
|
||||
const name = getStorage("userEmail") || getStorage("userName");
|
||||
if (name) setUserName(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -778,15 +629,6 @@ export default function Home() {
|
||||
|
||||
const isLoggedIn = paidType === "vip";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.loadingSpinner} />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getTopicConfig } from "../config";
|
||||
import { isPaid } from "@/app/cloudphone/lib/payment";
|
||||
|
||||
type TopicFooterProps = {
|
||||
topicId: string;
|
||||
};
|
||||
|
||||
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
|
||||
|
||||
export function TopicFooter({ topicId }: TopicFooterProps) {
|
||||
const config = getTopicConfig(topicId);
|
||||
const [paid, setPaid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-[var(--border)] py-6 sm:py-8">
|
||||
@@ -23,17 +35,30 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
|
||||
>
|
||||
模块列表
|
||||
</Link>
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
{paid ? (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
title="开通VIP解锁即时问答"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
{config ? `${config.icon} ${config.title} · 异度星球` : "异度星球 · 数字游民社群"}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||
import { getTopicConfig } from "../config";
|
||||
import { isPaid } from "@/app/cloudphone/lib/payment";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
|
||||
type TopicHeaderProps = {
|
||||
topicId: string;
|
||||
@@ -22,11 +22,7 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
const ctaHref = config?.externalUrl
|
||||
? config.externalUrl
|
||||
: paid
|
||||
? "/cloudphone/course/0/0"
|
||||
: config?.payUrl ?? "/";
|
||||
const isEnrollCta = !config?.externalUrl && !paid;
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -48,25 +44,55 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
|
||||
>
|
||||
首页
|
||||
</Link>
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
target={config?.externalUrl ? "_blank" : undefined}
|
||||
rel={config?.externalUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{config?.externalUrl ? "前往了解 →" : paid ? "进入课程 →" : "立即报名 →"}
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
title="开通VIP解锁即时问答"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</Link>
|
||||
)}
|
||||
{config?.externalUrl ? (
|
||||
<Link
|
||||
href={config.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
前往了解 →
|
||||
</Link>
|
||||
) : isEnrollCta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
立即报名 →
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/cloudphone/course/0/0"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
进入课程 →
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { getTopicConfig, getTopicCurriculum } from "../config";
|
||||
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
|
||||
import { ParticleBackground } from "@/app/cloudphone/components/ParticleBackground";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
|
||||
type TopicHeroProps = {
|
||||
topicId: string;
|
||||
@@ -33,11 +34,7 @@ export function TopicHero({ topicId }: TopicHeroProps) {
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const ctaHref = config.externalUrl
|
||||
? config.externalUrl
|
||||
: last
|
||||
? `/cloudphone/course/${last.moduleIndex}/${last.lessonIndex}`
|
||||
: config.payUrl;
|
||||
const isEnrollCta = !config.externalUrl && !last;
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||
@@ -82,14 +79,31 @@ export function TopicHero({ topicId }: TopicHeroProps) {
|
||||
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"
|
||||
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
|
||||
>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
target={config.externalUrl ? "_blank" : undefined}
|
||||
rel={config.externalUrl ? "noopener noreferrer" : undefined}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
{config.externalUrl ? "前往了解 →" : last ? "继续学习 →" : "立即报名 →"}
|
||||
</Link>
|
||||
{config.externalUrl ? (
|
||||
<Link
|
||||
href={config.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
前往了解 →
|
||||
</Link>
|
||||
) : isEnrollCta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerPay()}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
立即报名 →
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/cloudphone/course/${last!.moduleIndex}/${last!.lessonIndex}`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
继续学习 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{curriculum?.hasContent && progress.completed > 0 && (
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
{ value: "170+", label: "分钟视频" },
|
||||
{ value: "5", label: "大模块" },
|
||||
],
|
||||
payUrl: "/cloudphone/pay",
|
||||
payUrl: "/",
|
||||
},
|
||||
livestream: {
|
||||
id: "livestream",
|
||||
@@ -45,7 +45,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
{ value: "2", label: "大模块" },
|
||||
],
|
||||
moduleIndices: [1, 2],
|
||||
payUrl: "/cloudphone/pay",
|
||||
payUrl: "/",
|
||||
},
|
||||
indie: {
|
||||
id: "indie",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatRestoreDateShort } from "@/config/promo.config";
|
||||
import styles from "../vip.module.css";
|
||||
|
||||
@@ -9,6 +10,11 @@ type LandingCTAProps = {
|
||||
};
|
||||
|
||||
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
|
||||
const [restoreDate, setRestoreDate] = useState<string>("");
|
||||
useEffect(() => {
|
||||
setRestoreDate(formatRestoreDateShort());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
|
||||
<div
|
||||
@@ -23,7 +29,7 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: "1.35rem", margin: "0 0 0.5rem", color: "var(--foreground)" }}>
|
||||
限时特惠 · {formatRestoreDateShort()}恢复原价
|
||||
限时特惠 · {restoreDate || "—"}恢复原价
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--muted-foreground)", margin: "0 0 1rem" }}>
|
||||
阶梯定价 · 数字交付 · 不支持退款
|
||||
|
||||
@@ -108,18 +108,49 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
|
||||
>
|
||||
{EBOOKS_DISPLAY.map((e) => {
|
||||
const { id, href } = e;
|
||||
const isFree = "free" in e && e.free;
|
||||
const isLocked = "locked" in e && e.locked && onLockedClick;
|
||||
const cardContent = (
|
||||
<>
|
||||
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
|
||||
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
|
||||
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem", display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
《{e.title}》
|
||||
{isFree && (
|
||||
<span className={styles.freeBadge}>
|
||||
免费
|
||||
</span>
|
||||
)}
|
||||
{isLocked && (
|
||||
<span title="VIP专享" style={{ fontSize: "1rem" }}>🔒</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--muted-foreground)" }}>
|
||||
阅读 →
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: isLocked ? "var(--muted-foreground)" : "var(--muted-foreground)" }}>
|
||||
{isLocked ? "开通VIP解锁 →" : "阅读 →"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (isLocked) {
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={onLockedClick}
|
||||
className={styles.card}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "1.25rem",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return href ? (
|
||||
<Link
|
||||
key={id}
|
||||
|
||||
@@ -178,6 +178,15 @@
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.freeBadge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--muted);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 星球登录信息卡片 */
|
||||
.loginCard {
|
||||
margin-top: 1.25rem;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CLOUDPHONE_EBOOK_CONFIG: EbookConfig = {
|
||||
export const CLOUDPHONE_COURSE_CONFIG: CourseConfig = {
|
||||
courseId: "cloudphone",
|
||||
courseHomeHref: "/cloudphone",
|
||||
payHref: "/cloudphone/pay",
|
||||
payHref: "/",
|
||||
freeTrial: (m, l) => m === 0 && l < 2,
|
||||
videoStoragePrefix: "lms-cloudphone-video-",
|
||||
learningStoragePrefix: "lms-cloudphone-",
|
||||
|
||||
Reference in New Issue
Block a user