's''
This commit is contained in:
@@ -48,7 +48,7 @@ PC 扫码支付后自动检测逻辑(参考 nomadvip):
|
||||
2. 失败时 **直接向 zpay/xorpay 查询** 兜底(不依赖回调)
|
||||
3. 检测到 paid 后调用 complete-order → 跳转报名成功页
|
||||
|
||||
若仍无反应:点击「支付完成?点此手动检查」;确认 ZPAY_PID/KEY、XORPAY_AID/SECRET 与 salonapi 一致
|
||||
若仍无反应:确认 ZPAY_PID/KEY、XORPAY_AID/SECRET 与 salonapi 一致
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
} from "@/config/services.config";
|
||||
import { SITE_URL } from "@/app/lib/env";
|
||||
|
||||
/** ZPAY 创建订单较慢(约 6–10s),需延长超时避免手机浏览器发起支付超时 */
|
||||
export const maxDuration = 30;
|
||||
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
const SALON_PAY_PREFIX = "/salon";
|
||||
|
||||
@@ -165,23 +168,11 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d&&d.paid){doConfirm(0);return;}
|
||||
setTimeout(check,300);
|
||||
setTimeout(check,800);
|
||||
})
|
||||
.catch(function(){setTimeout(check,1000);});
|
||||
.catch(function(){setTimeout(check,1500);});
|
||||
}
|
||||
var manualBtn=document.createElement("button");
|
||||
manualBtn.textContent="支付完成?点此手动检查";
|
||||
manualBtn.style.cssText="margin-top:12px;padding:8px 16px;font-size:13px;color:#64748b;background:#f1f5f9;border:none;border-radius:8px;cursor:pointer";
|
||||
manualBtn.onclick=function(){
|
||||
manualBtn.disabled=true;
|
||||
manualBtn.textContent="检查中…";
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{cache:"no-store",credentials:"same-origin"})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){if(d&&d.paid){doConfirm(0);}else{manualBtn.disabled=false;manualBtn.textContent="支付完成?点此手动检查";}})
|
||||
.catch(function(){manualBtn.disabled=false;manualBtn.textContent="检查失败,重试";});
|
||||
};
|
||||
if(tip&&tip.parentNode){tip.parentNode.appendChild(manualBtn);}
|
||||
setTimeout(check,300);
|
||||
setTimeout(check,500);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
@@ -243,7 +234,7 @@ async function requestRedirect(
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
try {
|
||||
return await fetch(`${apiUrl}${path}`, {
|
||||
method: "POST",
|
||||
@@ -309,30 +300,33 @@ export async function POST(request: NextRequest) {
|
||||
device
|
||||
);
|
||||
|
||||
const created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
forcedProvider,
|
||||
clientIp
|
||||
);
|
||||
// 微信内:xorpay 收银台,需先 createPayOrder
|
||||
// PC/手机浏览器:zpay,参考 nomadvip 直接走 redirect,跳过 payh5 避免重复建单
|
||||
const isWechat = device === "wechat";
|
||||
let created: Awaited<ReturnType<typeof createPayOrder>> | null = null;
|
||||
|
||||
if (!wantHtml) {
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
params: created.params,
|
||||
payUrl: created.payUrl,
|
||||
provider: created.provider,
|
||||
order_id: created.orderId,
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (created.provider === "xorpay") {
|
||||
if (isWechat) {
|
||||
created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
"xorpay",
|
||||
clientIp
|
||||
);
|
||||
if (!wantHtml) {
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
params: created.params,
|
||||
payUrl: created.payUrl,
|
||||
provider: created.provider,
|
||||
order_id: created.orderId,
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: created.orderId,
|
||||
@@ -345,6 +339,7 @@ export async function POST(request: NextRequest) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// PC/手机:zpay 直接 redirect(手机 h5 得 302 跳转支付页,PC 得二维码页)
|
||||
const redirectPayload: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
@@ -366,10 +361,18 @@ export async function POST(request: NextRequest) {
|
||||
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) {
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
order_id: orderId,
|
||||
redirect_url: location,
|
||||
provider: "zpay",
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
return res;
|
||||
}
|
||||
const response = NextResponse.redirect(location);
|
||||
const orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
created.orderId;
|
||||
if (orderId) setPayOrderCookie(response, orderId);
|
||||
return response;
|
||||
}
|
||||
@@ -391,7 +394,7 @@ export async function POST(request: NextRequest) {
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const rawOrderId = String(resData.order_id || created.orderId).trim();
|
||||
const rawOrderId = String(resData.order_id || "").trim();
|
||||
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
|
||||
channel: resData.channel || "wxpay",
|
||||
confirmUrl: "/api/pay/complete-order",
|
||||
@@ -405,6 +408,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
undefined,
|
||||
clientIp
|
||||
);
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: String(resData.order_id || created.orderId).trim(),
|
||||
@@ -432,6 +445,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 兜底:redirect 失败时回退到 payh5
|
||||
created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
undefined,
|
||||
clientIp
|
||||
);
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
orderId: created.orderId,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function Footer() {
|
||||
<footer className="border-t border-stone-200 dark:border-stone-800 mt-12 py-8">
|
||||
<div className="max-w-[900px] mx-auto px-5 sm:px-8 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<Link
|
||||
href="/join"
|
||||
href="/join?volunteer=1"
|
||||
className="inline-flex items-center gap-1.5 mb-4 text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium"
|
||||
>
|
||||
<span aria-hidden>🙋</span> 招募志愿者
|
||||
|
||||
@@ -10,12 +10,36 @@ import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { getSessionsWithDates, HOSTS } from "@/config/home.config";
|
||||
|
||||
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
||||
|
||||
interface HomeContentProps {
|
||||
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
|
||||
}
|
||||
|
||||
const VOLUNTEER_BIO = "签到、茶歇准备、拍照摄像。";
|
||||
|
||||
function getPendingOrderId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
|
||||
const raw = cookie?.split("=")[1]?.trim();
|
||||
const decoded = raw ? decodeURIComponent(raw) : "";
|
||||
const fromStorage = localStorage.getItem("join_pay_order") || "";
|
||||
const value = decoded || fromStorage;
|
||||
const orderId = value.split("|")[0]?.trim() || "";
|
||||
return orderId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingOrder(): void {
|
||||
try {
|
||||
localStorage.removeItem("join_pay_order");
|
||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
const [volunteer, setVolunteer] = useState(initialVolunteer);
|
||||
|
||||
@@ -26,6 +50,55 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 支付完成后再次打开首页:后台完成订单并刷新用户信息(手机浏览器唤醒微信支付后,用户可能直接回首页,notify 可能未到,需轮询)
|
||||
useEffect(() => {
|
||||
const orderId = getPendingOrderId();
|
||||
if (!orderId) return;
|
||||
|
||||
let cancelled = false;
|
||||
const tryComplete = async (): Promise<boolean> => {
|
||||
const r = await fetch("/api/salon/complete-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d?.ok) return false;
|
||||
if (d?.token && d?.record) {
|
||||
fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
||||
credentials: "include",
|
||||
}).catch(() => {});
|
||||
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
|
||||
if (d.record?.wechatId) {
|
||||
try {
|
||||
localStorage.setItem(WECHAT_STORAGE_KEY, d.record.wechatId);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
clearPendingOrder();
|
||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
||||
return true;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const retryDelays = [1500, 2500, 3500, 4500, 5500];
|
||||
for (let i = 0; i <= retryDelays.length; i++) {
|
||||
if (cancelled) return;
|
||||
if (await tryComplete()) return;
|
||||
if (i < retryDelays.length && !cancelled) {
|
||||
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
|
||||
<Header />
|
||||
@@ -138,13 +211,18 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
{HOSTS.map((host) => {
|
||||
const isHost2 = host.id === "host-2";
|
||||
const displayHost = isHost2 && volunteer
|
||||
? { ...host, name: volunteer.nickname, avatar: volunteer.avatar || host.avatar, bio: VOLUNTEER_BIO }
|
||||
? {
|
||||
...host,
|
||||
name: volunteer.nickname,
|
||||
avatar: volunteer.avatar || host.avatar,
|
||||
bio: VOLUNTEER_BIO,
|
||||
link: volunteer.xiaohongshu_url || host.link,
|
||||
}
|
||||
: host;
|
||||
return (
|
||||
<HostLink
|
||||
key={host.id}
|
||||
host={displayHost}
|
||||
noLink
|
||||
{...(isHost2 && !volunteer && {
|
||||
ctaText: "立即申请",
|
||||
showFreeBadge: true,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
getPayEnv,
|
||||
redirectToPay,
|
||||
redirectToPayH5,
|
||||
payWechatXorpaySync,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
import type { PayChannel } from "@/app/lib/payment";
|
||||
@@ -70,15 +72,20 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
return;
|
||||
}
|
||||
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/join/paid` : "/join/paid";
|
||||
const env = getPayEnv();
|
||||
const channel: PayChannel = "wxpay";
|
||||
redirectToPay({
|
||||
const device = getDeviceFromEnv(getPayEnv());
|
||||
const base = {
|
||||
user_id: data.user_id,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device: getDeviceFromEnv(env),
|
||||
channel: "wxpay" as PayChannel,
|
||||
useJoinConfig: true,
|
||||
});
|
||||
};
|
||||
if (device === "wechat") {
|
||||
payWechatXorpaySync(base);
|
||||
} else if (device === "h5") {
|
||||
await redirectToPayH5(base);
|
||||
} else {
|
||||
redirectToPay({ ...base, device });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
|
||||
@@ -4,14 +4,58 @@ import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import ThemeToggle from "../components/ThemeToggle";
|
||||
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
|
||||
import { redirectToPay, getDeviceFromEnv } from "@/app/lib/payment/client";
|
||||
import {
|
||||
redirectToPay,
|
||||
redirectToPayH5,
|
||||
payWechatXorpaySync,
|
||||
getDeviceFromEnv,
|
||||
getPayEnv,
|
||||
} from "@/app/lib/payment";
|
||||
|
||||
const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
|
||||
|
||||
/** 微信号仅允许英文字母、数字、下划线、连字符 */
|
||||
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
|
||||
|
||||
/** 从粘贴文本中提取小红书 URL,如 "我在小红书收获了536次赞与收藏>> https://xhslink.com/m/9H50QYiVOVx" */
|
||||
function extractXiaohongshuUrl(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "";
|
||||
const urlMatch = trimmed.match(/https?:\/\/[^\s<>"']*(?:xhslink\.com|xiaohongshu\.com)[^\s<>"']*/i);
|
||||
if (urlMatch) {
|
||||
return urlMatch[0].replace(/[^\w/:?#&=.-]+$/, "").trim();
|
||||
}
|
||||
const anyUrlMatch = trimmed.match(/https?:\/\/[^\s<>"']+/);
|
||||
if (anyUrlMatch) {
|
||||
return anyUrlMatch[0].replace(/[^\w/:?#&=.-]+$/, "").trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
|
||||
function getPendingOrderId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith(`${ORDER_COOKIE}=`));
|
||||
const raw = cookie?.split("=")[1]?.trim();
|
||||
const decoded = raw ? decodeURIComponent(raw) : "";
|
||||
const fromStorage = localStorage.getItem(ORDER_COOKIE) || "";
|
||||
const value = decoded || fromStorage;
|
||||
const orderId = value.split("|")[0]?.trim() || "";
|
||||
return orderId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingOrder(): void {
|
||||
try {
|
||||
localStorage.removeItem(ORDER_COOKIE);
|
||||
document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
hasRecord: boolean;
|
||||
@@ -95,6 +139,7 @@ export default function JoinPage() {
|
||||
|
||||
const [fromPaid, setFromPaid] = useState(false);
|
||||
const [fromVolunteer, setFromVolunteer] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const initialWechatCheckDone = useRef(false);
|
||||
|
||||
const fetchByWechat = async (w: string) => {
|
||||
@@ -145,7 +190,11 @@ export default function JoinPage() {
|
||||
};
|
||||
|
||||
const handleUrlBlur = async () => {
|
||||
const url = xiaohongshuUrl.trim();
|
||||
const extracted = extractXiaohongshuUrl(xiaohongshuUrl);
|
||||
if (extracted !== xiaohongshuUrl) {
|
||||
setXiaohongshuUrl(extracted);
|
||||
}
|
||||
const url = extracted;
|
||||
if (!url) {
|
||||
setUrlError(null);
|
||||
setProfile(null);
|
||||
@@ -291,18 +340,32 @@ export default function JoinPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayNow = () => {
|
||||
const handlePayNow = async () => {
|
||||
const rec = userRecord?.record;
|
||||
if (!rec?.user_id) return;
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const device = typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod|webOS|Mobile/i.test(navigator.userAgent) ? "h5" : "pc";
|
||||
redirectToPay({
|
||||
user_id: rec.user_id,
|
||||
return_url: `${origin}/join/paid`,
|
||||
useJoinConfig: true,
|
||||
channel: "wxpay",
|
||||
device: getDeviceFromEnv(device),
|
||||
});
|
||||
if (!rec?.user_id || paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const base = {
|
||||
user_id: rec.user_id,
|
||||
return_url: `${origin}/join/paid`,
|
||||
useJoinConfig: true,
|
||||
channel: "wxpay" as const,
|
||||
};
|
||||
const device = getDeviceFromEnv(getPayEnv());
|
||||
if (device === "wechat") {
|
||||
payWechatXorpaySync(base);
|
||||
} else if (device === "h5") {
|
||||
await redirectToPayH5(base);
|
||||
} else {
|
||||
redirectToPay({ ...base, device });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "支付发起失败";
|
||||
alert(msg.includes("abort") ? "请求超时,请检查网络后重试" : msg);
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -326,6 +389,104 @@ export default function JoinPage() {
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
// 手机浏览器支付后若回到 /join:有 pending order 时主动轮询 complete-order,触发后端更新 solanRed(ZPAY notify 可能未到)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const orderId = getPendingOrderId();
|
||||
if (!orderId) return;
|
||||
const shouldComplete =
|
||||
userRecord?.hasRecord &&
|
||||
userRecord?.isWechatFriend &&
|
||||
!userRecord?.vip &&
|
||||
!userRecord?.isComplete;
|
||||
if (!shouldComplete) return;
|
||||
|
||||
let cancelled = false;
|
||||
const tryComplete = async (): Promise<boolean> => {
|
||||
const r = await fetch("/api/salon/complete-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d?.ok) return false;
|
||||
if (d?.token && d?.record) {
|
||||
fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
||||
credentials: "include",
|
||||
}).catch(() => {});
|
||||
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
|
||||
}
|
||||
clearPendingOrder();
|
||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
||||
return true;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const retryDelays = [1500, 2500, 3500, 4500, 5500];
|
||||
for (let i = 0; i <= retryDelays.length; i++) {
|
||||
if (cancelled) return;
|
||||
if (await tryComplete()) {
|
||||
if (wechat.trim()) fetchByWechat(wechat.trim());
|
||||
return;
|
||||
}
|
||||
if (i < retryDelays.length && !cancelled) {
|
||||
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
userRecord?.hasRecord,
|
||||
userRecord?.isWechatFriend,
|
||||
userRecord?.vip,
|
||||
userRecord?.isComplete,
|
||||
wechat,
|
||||
]);
|
||||
|
||||
// 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功
|
||||
// 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join,故 h5 也需轮询(参考 nomadvip)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const w = wechat.trim();
|
||||
if (!w || !WECHAT_REGEX.test(w)) return;
|
||||
const device = getDeviceFromEnv(getPayEnv());
|
||||
const isWechatOrH5 = device === "wechat" || device === "h5";
|
||||
if (!isWechatOrH5) return;
|
||||
const shouldPoll =
|
||||
userRecord?.hasRecord &&
|
||||
userRecord?.isWechatFriend &&
|
||||
!userRecord?.vip &&
|
||||
!userRecord?.isComplete;
|
||||
if (!shouldPoll) return;
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
for (let attempt = 0; attempt < 6 && !cancelled; attempt++) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
if (cancelled) return;
|
||||
await fetchByWechat(w);
|
||||
if (cancelled) return;
|
||||
// fetchByWechat 会更新 userRecord,下次渲染会走 showSuccess,此处无需再判断
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
wechat,
|
||||
userRecord?.hasRecord,
|
||||
userRecord?.isWechatFriend,
|
||||
userRecord?.vip,
|
||||
userRecord?.isComplete,
|
||||
]);
|
||||
|
||||
// 成功报名:已支付 或 支付成功跳转 或 (好友且vip) —— 仅以数据库状态为准,不依赖 submitted
|
||||
const showSuccess = fromPaid || (userRecord?.hasRecord && userRecord.isComplete) || (userRecord?.hasRecord && userRecord.isWechatFriend && userRecord.vip);
|
||||
|
||||
@@ -427,9 +588,17 @@ export default function JoinPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayNow}
|
||||
className="mt-6 w-full rounded-xl bg-amber-600 py-3.5 text-base font-medium text-white transition-colors hover:bg-amber-700"
|
||||
disabled={paying}
|
||||
className="mt-6 w-full rounded-xl bg-amber-600 py-3.5 text-base font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
立即报名
|
||||
{paying ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
跳转中…
|
||||
</>
|
||||
) : (
|
||||
"立即报名"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,11 +718,11 @@ export default function JoinPage() {
|
||||
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 小红书主页链接</label>
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 mb-1.5">💡 建议填写,可加快审核</p>
|
||||
<input
|
||||
type="url"
|
||||
type="text"
|
||||
value={xiaohongshuUrl}
|
||||
onChange={(e) => { setXiaohongshuUrl(e.target.value); setUrlError(null); }}
|
||||
onBlur={handleUrlBlur}
|
||||
placeholder="https://www.xiaohongshu.com/user/profile/xxx"
|
||||
placeholder="https://www.xiaohongshu.com/user/profile/xxx 或粘贴分享文案"
|
||||
className="form-input"
|
||||
/>
|
||||
{validating && <p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">🔄 校验中…</p>}
|
||||
|
||||
@@ -2,6 +2,18 @@ import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config
|
||||
|
||||
export type PayChannel = "wxpay" | "alipay";
|
||||
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
|
||||
function savePendingOrder(orderId: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
try {
|
||||
const v = `${orderId}|${Date.now()}`;
|
||||
document.cookie = `${ORDER_COOKIE}=${encodeURIComponent(v)}; path=/; max-age=900`;
|
||||
localStorage.setItem(ORDER_COOKIE, v);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 同步表单 POST,用于 PC 和微信内(微信内避免异步 fetch 导致手势失效) */
|
||||
export function redirectToPay(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
@@ -44,6 +56,121 @@ export function redirectToPay(params: {
|
||||
payForm.submit();
|
||||
}
|
||||
|
||||
/** 微信内:同步表单 POST 到 /api/pay,避免异步 fetch 后 form.submit 被微信拦截(参考 nomadvip) */
|
||||
export function payWechatXorpaySync(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee?: number;
|
||||
type?: string;
|
||||
channel: PayChannel;
|
||||
useJoinConfig?: boolean;
|
||||
}): void {
|
||||
const baseConfig = getPaymentConfig();
|
||||
const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
|
||||
const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
|
||||
const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
|
||||
|
||||
const payForm = document.createElement("form");
|
||||
payForm.method = "POST";
|
||||
payForm.action = "/api/pay";
|
||||
payForm.target = "_self";
|
||||
payForm.style.display = "none";
|
||||
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(total_fee)],
|
||||
["type", type],
|
||||
["channel", params.channel],
|
||||
["device", "wechat"],
|
||||
["html", "1"],
|
||||
];
|
||||
|
||||
fields.forEach(([k, v]) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = k;
|
||||
input.value = v;
|
||||
payForm.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(payForm);
|
||||
payForm.submit();
|
||||
}
|
||||
|
||||
/** 手机浏览器 H5:异步 fetch 获取 redirect_url 后跳转,支持 ZPAY 微信 H5 唤醒(参考 nomadvip + payjsapi) */
|
||||
export async function redirectToPayH5(params: {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee?: number;
|
||||
type?: string;
|
||||
channel: PayChannel;
|
||||
useJoinConfig?: boolean;
|
||||
}): Promise<void> {
|
||||
const baseConfig = getPaymentConfig();
|
||||
const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
|
||||
const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
|
||||
const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 28000);
|
||||
const res = await fetch("/api/pay", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_id: params.user_id,
|
||||
return_url: params.return_url,
|
||||
total_fee,
|
||||
type,
|
||||
channel: params.channel,
|
||||
device: "h5",
|
||||
html: "0",
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeoutId));
|
||||
|
||||
if (res.status >= 301 && res.status <= 308) {
|
||||
const location = res.headers.get("location");
|
||||
if (location) {
|
||||
const orderId = res.headers.get("x-pay-order-id")?.trim();
|
||||
if (orderId) savePendingOrder(orderId);
|
||||
window.location.href = location;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.ok) {
|
||||
alert(data?.error || "支付发起失败");
|
||||
return;
|
||||
}
|
||||
if (data?.order_id) savePendingOrder(data.order_id);
|
||||
if (data?.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
return;
|
||||
}
|
||||
if (data?.params && data?.payUrl) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
alert("支付发起失败");
|
||||
}
|
||||
|
||||
export function getDeviceFromEnv(env: "pc" | "h5" | "wechat"): "pc" | "h5" | "wechat" {
|
||||
if (env === "wechat") return "wechat";
|
||||
return env === "pc" ? "pc" : "h5";
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { redirectToPay, getDeviceFromEnv } from "./client";
|
||||
export {
|
||||
redirectToPay,
|
||||
redirectToPayH5,
|
||||
payWechatXorpaySync,
|
||||
getDeviceFromEnv,
|
||||
} from "./client";
|
||||
export { getPayEnv } from "../payEnv";
|
||||
export { usePayStatusPoll } from "./usePayStatusPoll";
|
||||
export type { PayChannel } from "./client";
|
||||
|
||||
@@ -58,7 +58,7 @@ export const PAST_SESSIONS = [
|
||||
/** 发起人 - 点击跳转小红书 */
|
||||
export const HOSTS = [
|
||||
{ id: "host-1", name: "发起人", avatar: "/images/11.webp", bio: "数字游民社区发起人,热爱连接同频的人。", link: "https://xhslink.com/m/9H50QYiVOVx" },
|
||||
{ id: "host-2", name: "志愿者", avatar: "/images/volunteer-mystery.svg", bio: "签到、茶歇准备、拍照摄像。", link: "/volunteer" },
|
||||
{ id: "host-2", name: "志愿者", avatar: "/images/volunteer-mystery.svg", bio: "签到、茶歇准备、拍照摄像。", link: "/join?volunteer=1" },
|
||||
];
|
||||
|
||||
/** 数字游民社区 - 符合条件时显示的微信二维码 */
|
||||
|
||||
Reference in New Issue
Block a user