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

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