's''
This commit is contained in:
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user