This commit is contained in:
eric
2026-03-16 09:32:35 -05:00
parent c54c236d1b
commit 14b0cd2429
21 changed files with 1058 additions and 706 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
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";
@@ -11,9 +11,21 @@ const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
/** 微信号仅允许英文字母、数字、下划线、连字符 */
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
const WECHAT_STORAGE_KEY = "salon_join_wechat";
interface UserRecord {
hasRecord: boolean;
record: { user_id?: string; amount?: number; order_id?: string; vip?: boolean } | null;
record: {
id?: string;
wechatId?: string;
user_id?: string;
nickname?: string;
media?: string;
amount?: number;
order_id?: string;
vip?: boolean;
is_volunteer?: boolean;
} | null;
isComplete: boolean;
isWechatFriend: boolean;
isApproved: boolean;
@@ -21,6 +33,39 @@ interface UserRecord {
vip: boolean;
}
function UserProfileCard({ record, showVolunteerTag }: { record: UserRecord["record"]; showVolunteerTag?: boolean }) {
if (!record || (!record.nickname && !record.media && !record.wechatId)) return null;
const isVolunteer = showVolunteerTag && record.is_volunteer;
return (
<div className="mt-6 flex items-center justify-center gap-3 rounded-xl bg-stone-50 dark:bg-stone-800/50 px-4 py-3">
<div className="relative shrink-0">
{record.media ? (
<img
src={record.media}
alt=""
className="h-12 w-12 rounded-full object-cover border border-stone-200 dark:border-stone-600"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40 text-xl">
👤
</div>
)}
{isVolunteer && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white whitespace-nowrap">
志愿者
</span>
)}
</div>
<div className="text-left">
<p className="font-medium text-stone-800 dark:text-stone-100">{record.nickname || "—"}</p>
{record.wechatId && (
<p className="text-xs text-stone-500 dark:text-stone-400 font-mono">微信号: {record.wechatId}</p>
)}
</div>
</div>
);
}
export default function JoinPage() {
const [wechat, setWechat] = useState("");
const [userRecord, setUserRecord] = useState<UserRecord | null>(null);
@@ -48,9 +93,9 @@ export default function JoinPage() {
const [error, setError] = useState<string | null>(null);
const [hydrated, setHydrated] = useState(false);
const [fromPaid, setFromPaid] = useState(() =>
typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1"
);
const [fromPaid, setFromPaid] = useState(false);
const [fromVolunteer, setFromVolunteer] = useState(false);
const initialWechatCheckDone = useRef(false);
const fetchByWechat = async (w: string) => {
if (!w.trim()) return;
@@ -79,6 +124,11 @@ export default function JoinPage() {
const w = wechat.trim();
if (!w) {
setUserRecord(null);
try {
typeof window !== "undefined" && localStorage.removeItem(WECHAT_STORAGE_KEY);
} catch {
/* ignore */
}
return;
}
if (!WECHAT_REGEX.test(w)) {
@@ -86,6 +136,11 @@ export default function JoinPage() {
return;
}
setError(null);
try {
typeof window !== "undefined" && localStorage.setItem(WECHAT_STORAGE_KEY, w);
} catch {
/* ignore */
}
fetchByWechat(w);
};
@@ -181,7 +236,7 @@ export default function JoinPage() {
intro: "",
age_range: ageRange,
city: "深圳",
session: "digital-nomad",
session: fromVolunteer ? "volunteer" : "digital-nomad",
education: "",
graduation_year: "",
agree_rules: true,
@@ -191,6 +246,8 @@ export default function JoinPage() {
why_join: "",
region: "",
available_time: "",
qualify, // 女性且小红书帖子>10 时自动通过审核
is_volunteer: fromVolunteer,
}),
});
const data = await res.json();
@@ -199,6 +256,8 @@ export default function JoinPage() {
}
setQualify(qualify);
setSubmitted(true);
// 提交成功后立即按微信号查询,用真实记录状态展示(审核中/审核通过等),避免刷新后状态不一致
fetchByWechat(wechat.trim());
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
@@ -206,6 +265,32 @@ export default function JoinPage() {
}
};
const clearWechatAndRecord = () => {
setUserRecord(null);
setWechat("");
setXiaohongshuUrl("");
setProfession("");
setGender("");
setAgeRange("");
setAgreeRules(true);
setUrlError(null);
setProfile(null);
setValidating(false);
setSubmitted(false);
setSubmitting(false);
setQualify(false);
setError(null);
try {
if (typeof window !== "undefined") {
localStorage.removeItem(WECHAT_STORAGE_KEY);
localStorage.removeItem("join_pay_order");
document.cookie = "join_pay_order=; path=/; max-age=0";
}
} catch {
/* ignore */
}
};
const handlePayNow = () => {
const rec = userRecord?.record;
if (!rec?.user_id) return;
@@ -221,10 +306,27 @@ export default function JoinPage() {
};
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
setFromPaid(params.get("paid") === "1");
setFromVolunteer(params.get("volunteer") === "1");
if (!initialWechatCheckDone.current) {
initialWechatCheckDone.current = true;
try {
const stored = localStorage.getItem(WECHAT_STORAGE_KEY);
const w = (stored || "").trim();
if (w && WECHAT_REGEX.test(w)) {
setWechat(w);
fetchByWechat(w);
}
} catch {
/* ignore */
}
}
setHydrated(true);
}, []);
// 成功报名:已支付 或 支付成功跳转 或 (好友且vip)
// 成功报名:已支付 或 支付成功跳转 或 (好友且vip) —— 仅以数据库状态为准,不依赖 submitted
const showSuccess = fromPaid || (userRecord?.hasRecord && userRecord.isComplete) || (userRecord?.hasRecord && userRecord.isWechatFriend && userRecord.vip);
// 有数据、非好友 → 只展示状态页:审核中/审核通过/审核拒绝
@@ -233,18 +335,20 @@ export default function JoinPage() {
// 有数据、好友、非vip、未支付 → 支付状态页
const showPayPage = userRecord?.hasRecord && userRecord.isWechatFriend && !userRecord.vip && !userRecord.isComplete;
if (submitted || showSuccess) {
if (showSuccess) {
const baseRecord = userRecord?.record ?? null;
const displayRecord = baseRecord
? { ...baseRecord, wechatId: baseRecord.wechatId || wechat || undefined }
: (wechat ? { wechatId: wechat } : null);
return (
<div className="flex min-h-screen items-center justify-center bg-[#faf8f5] dark:bg-stone-950 px-4 py-8">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-stone-900 p-6 sm:p-10 text-center shadow-lg border border-stone-200 dark:border-stone-800">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-4xl">🎉</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">报名成功</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">感谢报名,活动前会联系你确认~</p>
<p className="mt-6 text-xs text-stone-400 dark:text-stone-500 leading-relaxed whitespace-pre-line">
{`本场收取少量场地分摊费29/人
用于覆盖民宿/场地基础成本。
通过审核后再确认名额,未确认无需支付。`}
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
{displayRecord?.is_volunteer ? "要提前到场,金额可退" : "感谢报名,活动前会联系你确认~"}
</p>
<UserProfileCard record={displayRecord} showVolunteerTag />
<Link href="/" className="mt-6 sm:mt-8 inline-flex items-center justify-center rounded-xl bg-amber-600 px-6 sm:px-8 py-2.5 sm:py-3 font-medium text-white text-sm sm:text-base transition-colors hover:bg-amber-700">
🏠 返回首页
</Link>
@@ -281,18 +385,16 @@ export default function JoinPage() {
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。</p>
</>
)}
<p className="mt-6 text-xs text-stone-400 dark:text-stone-500 leading-relaxed whitespace-pre-line">
{`本场收取少量场地分摊费29/人
用于覆盖民宿/场地基础成本。
通过审核后再确认名额,未确认无需支付。`}
</p>
<button
type="button"
onClick={() => { setUserRecord(null); setWechat(""); }}
className="mt-6 text-sm text-stone-500 hover:text-amber-600"
>
更换微信号
</button>
<UserProfileCard record={userRecord?.record ?? null} showVolunteerTag />
{status !== "approved" && (
<button
type="button"
onClick={clearWechatAndRecord}
className="mt-6 text-sm text-stone-500 hover:text-amber-600"
>
重新填写报名表
</button>
)}
<Link href="/" className="mt-4 block text-sm text-amber-600 hover:text-amber-700">
🏠 返回首页
</Link>
@@ -314,7 +416,14 @@ export default function JoinPage() {
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-2xl p-6 sm:p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-50 dark:bg-amber-900/30 text-4xl">💰</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">支付场地茶歇费</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">29元/人,用于覆盖民宿/场地基础成本</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">1元/人,用于覆盖民宿/场地基础成本</p>
<UserProfileCard record={userRecord?.record ?? null} showVolunteerTag />
{userRecord?.record?.is_volunteer && (
<p className="mt-3 text-xs text-emerald-600 dark:text-emerald-400">
🌿 志愿者预付活动结束可退<br />
用于防止临时爽约
</p>
)}
<button
type="button"
onClick={handlePayNow}
@@ -322,13 +431,6 @@ export default function JoinPage() {
>
立即报名
</button>
<button
type="button"
onClick={() => { setUserRecord(null); setWechat(""); }}
className="mt-3 text-sm text-stone-500 hover:text-stone-700"
>
更换微信号
</button>
</div>
</div>
</div>
@@ -419,6 +521,16 @@ export default function JoinPage() {
</div>
</div>
<div className="px-4 sm:px-6 py-5 sm:py-6">
{fromVolunteer && (
<div className="mb-6 rounded-xl bg-amber-50 dark:bg-amber-900/20 p-4">
<h3 className="font-semibold text-stone-800 dark:text-stone-100 text-sm mb-2">🙌 志愿者工作内容</h3>
<ul className="text-xs sm:text-sm text-stone-600 dark:text-stone-400 space-y-1">
<li>· 签到:现场签到、引导参与者</li>
<li>· 茶歇准备:协助布置茶歇、收拾整理</li>
<li>· 拍照摄像:记录活动现场,分享精彩瞬间</li>
</ul>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💬 微信号 <span className="text-red-500">*</span></label>
@@ -460,6 +572,14 @@ export default function JoinPage() {
<option value="男">男</option>
</select>
</div>
{fromVolunteer && (
<div>
<label className="flex items-center gap-3 cursor-default">
<input type="checkbox" checked readOnly disabled className="rounded border-stone-300 text-amber-600" />
<span className="text-sm font-medium text-stone-700 dark:text-stone-300">🙌 志愿者</span>
</label>
</div>
)}
<div className="hidden">
<select value={ageRange} onChange={(e) => setAgeRange(e.target.value)} className="form-input">
<option value="">请选择</option>
@@ -476,14 +596,14 @@ export default function JoinPage() {
<p className="rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-600 dark:text-red-400">⚠️ {error}</p>
)}
<button type="submit" disabled={submitting || !!urlError || validating} className="w-full rounded-xl bg-amber-600 py-3.5 text-base font-medium text-white transition-colors hover:bg-amber-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? "🔄 提交中…" : "📤 提交报名"}
{submitting ? "🔄 提交中…" : fromVolunteer ? "📤 提交志愿者申请" : "📤 提交报名"}
</button>
</form>
</div>
</div>
<div className="mt-4 text-center">
<button type="button" onClick={() => { setUserRecord(null); setWechat(""); }} className="text-xs sm:text-sm text-stone-500 hover:text-amber-600">
更换微信号
<button type="button" onClick={clearWechatAndRecord} className="text-xs sm:text-sm text-stone-500 hover:text-amber-600">
重新填写报名表
</button>
<span className="mx-2 text-stone-300">|</span>
<Link href="/" className="text-xs sm:text-sm text-stone-500 hover:text-amber-600">🏠 返回首页</Link>

View File

@@ -3,6 +3,8 @@
import { useState, useEffect } from "react";
import Link from "next/link";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
@@ -14,11 +16,12 @@ export default function JoinPaidPage() {
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
const [wechatId, setWechatId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string; record?: { wechatId?: string } }> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -40,7 +43,7 @@ export default function JoinPaidPage() {
}
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return { ok: true };
return { ok: true, record: d?.record };
};
const run = async () => {
@@ -73,7 +76,10 @@ export default function JoinPaidPage() {
const result = await tryComplete(orderId);
if (result.ok) {
clearPendingOrder();
if (!cancelled) setCompleteStatus("success");
if (!cancelled) {
setCompleteStatus("success");
if (result.record?.wechatId) setWechatId(result.record.wechatId);
}
return;
}
lastError = result.error || "unknown";
@@ -94,6 +100,15 @@ export default function JoinPaidPage() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
try {
const stored = localStorage.getItem(WECHAT_STORAGE_KEY)?.trim();
if (stored) setWechatId(stored);
} catch {
/* ignore */
}
}, []);
const COUNTDOWN_SECONDS = 5;
useEffect(() => {
if (completeStatus !== "success") return;
@@ -123,12 +138,15 @@ export default function JoinPaidPage() {
<p className="mt-3 text-slate-500 dark:text-slate-400">
{completeStatus === "success"
? countdown != null
? `${countdown} 秒后跳转报名页`
? `${countdown} 秒后跳转报名成功页`
: "感谢您的支持"
: completeStatus === "error"
? "请稍后刷新页面重试"
: "正在确认支付状态…"}
</p>
{wechatId && (
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500 font-mono">微信号: {wechatId}</p>
)}
{completeStatus === "error" && errorDetail && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{errorDetail}</p>
)}
@@ -141,12 +159,18 @@ export default function JoinPaidPage() {
刷新重试
</button>
)}
<Link
href="/join?paid=1"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
← 查看报名状态
</Link>
{completeStatus === "pending" ? (
<span className="mt-8 inline-flex items-center gap-2 rounded-full bg-stone-200 dark:bg-stone-700 px-8 py-3 font-semibold text-stone-400 dark:text-stone-500 cursor-not-allowed">
← 查看报名状态
</span>
) : (
<Link
href="/join?paid=1"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
← 查看报名状态
</Link>
)}
</div>
</div>
);