This commit is contained in:
eric
2026-03-16 11:45:38 -05:00
parent 3e73917ad1
commit c5e057f0ed
9 changed files with 482 additions and 72 deletions

View File

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

View File

@@ -7,6 +7,9 @@ import {
} from "@/config/services.config";
import { SITE_URL } from "@/app/lib/env";
/** ZPAY 创建订单较慢(约 610s需延长超时避免手机浏览器发起支付超时 */
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,
});

View File

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

View File

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

View File

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

View File

@@ -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触发后端更新 solanRedZPAY 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>}

View File

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

View File

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

View File

@@ -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" },
];
/** 数字游民社区 - 符合条件时显示的微信二维码 */