diff --git a/README.md b/README.md index 1f8e175..64536c8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ### 后端 (payjsapi) - 与 cnomadcna 相同,需配置 PocketBase、支付渠道等 -- **PocketBase 需创建 `solanRed` 集合**(可克隆自 solan),字段:user_id, nickname, occupation, reason, wechatId, gender, education, gradYear, phone, single, media, type, order_id, amount, is_volunteer(布尔), xiaohongshu_url +- **PocketBase 需创建 `solanRed` 集合**(可克隆自 solan),字段:user_id, nickname, occupation, reason, wechatId, gender, education, gradYear, phone, single, media, type, order_id, amount, is_volunteer(布尔), is_approved(布尔,审核通过), is_rejected(布尔,审核拒绝), is_wechat_friend(布尔,是否微信好友,默认 false), xiaohongshu_url, vip(布尔) ## 启动 diff --git a/app/api/join/by-wechat/route.ts b/app/api/join/by-wechat/route.ts new file mode 100644 index 0000000..b3c91d2 --- /dev/null +++ b/app/api/join/by-wechat/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { getSalonApi } from "@/app/lib/salonApi"; + +const empty = { + hasRecord: false, + record: null, + isComplete: false, + isWechatFriend: false, + isApproved: false, + isRejected: false, + vip: false, +}; + +/** 按 wechatId 查询 solanRed 记录,优先 salonapi */ +export async function GET(request: NextRequest) { + const wechatId = request.nextUrl.searchParams.get("wechat_id")?.trim(); + if (!wechatId) { + return NextResponse.json(empty); + } + + try { + const { status, data } = await getSalonApi(request, `/api/salon/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}`); + if (status === 200 && data && typeof data === "object") { + return NextResponse.json(data); + } + } catch { + // salonapi 不可用,回退到直连 PocketBase + } + + const token = await getAdminToken(); + if (!token) { + return NextResponse.json(empty); + } + + const pb = getPocketBaseConfig(); + const base = pb.url.replace(/\/$/, ""); + const escaped = wechatId.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const filter = encodeURIComponent(`wechatId = "${escaped}"`); + + try { + const res = await fetch( + `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=1`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + } + ); + if (!res.ok) return NextResponse.json(empty); + const data = await res.json(); + const items = Array.isArray(data?.items) ? data.items : []; + const rec = items[0] ?? null; + const hasRecord = !!rec; + const amount = rec ? Number(rec.amount) || 0 : 0; + const isComplete = hasRecord && !!rec.order_id && amount > 0; + const isWechatFriend = !!rec?.is_wechat_friend; + const isApproved = !!rec?.is_approved; + const isRejected = !!rec?.is_rejected; + const vip = !!rec?.vip; + + return NextResponse.json({ + hasRecord, + record: rec, + isComplete, + isWechatFriend, + isApproved, + isRejected, + vip, + }); + } catch { + return NextResponse.json(empty); + } +} diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 05cfa2f..61cbb49 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -46,12 +46,7 @@ export async function POST(request: NextRequest) { const region = String(formData.region || "").trim(); const availableTime = String(formData.available_time || "").trim(); const isVolunteer = !!formData.is_volunteer; - if (!intro && (status || activityType || whyJoin || region || availableTime)) { - const extra = [status && `状态:${status}`, activityType && `活动偏好:${activityType}`, whyJoin && `原因:${whyJoin}`, region && `区域:${region}`, availableTime && `时间:${availableTime}`].filter(Boolean).join(" | "); - intro = extra; - } - - const isDigitalNomad = session === "digital-nomad"; + /** reason 仅存自我介绍,不合并其他字段 */ const contact = wechatOrPhone || wechat || phone; if (!nickname || !session || !agreeRules) { return NextResponse.json( @@ -69,18 +64,19 @@ export async function POST(request: NextRequest) { const userId = getUserIdFromCookie(request) || generateAnonId(); const ageRangeForRecord = ageRange || ""; + /** occupation: 职业/城市,reason: 仅自我介绍 */ + const occupationValue = profession || city; - const record = { + const record: Record = { user_id: userId, nickname, - occupation: isDigitalNomad ? profession : city, + occupation: occupationValue, reason: intro, wechatId: wechat || wechatOrPhone, xiaohongshu_url: xiaohongshuUrl, gender, education: education, gradYear: graduationYear, - phone: phone || wechatOrPhone, single: "", media: xiaohongshuAvatarUrl, type: session, @@ -90,7 +86,9 @@ export async function POST(request: NextRequest) { first_time: firstTime, is_volunteer: isVolunteer, }; - + if (phone.trim()) { + record.phone = phone.trim(); + } try { const { status, data } = await postSalonApi(request, "/api/salon/join", record); if (status >= 500) { @@ -135,26 +133,28 @@ async function tryPocketBaseDirect( const base = pb.url.replace(/\/$/, ""); const url = `${base}/api/collections/${pb.joinCollection}/records`; - const reasonParts = [record.reason, record.age_range, record.first_time]; - if (record.xiaohongshu_url) reasonParts.push(`小红书:${record.xiaohongshu_url}`); const body: Record = { user_id: record.user_id, nickname: record.nickname, occupation: record.occupation ?? "", - reason: reasonParts.filter(Boolean).join(" | "), + reason: record.reason ?? "", wechatId: record.wechatId ?? "", gender: record.gender ?? "", education: record.education ?? "", gradYear: record.gradYear ?? "", - phone: record.phone ?? "", single: record.single ?? "", media: record.media ?? "", type: record.type ?? "", order_id: record.order_id ?? "", amount: record.amount ?? 0, - is_volunteer: record.is_volunteer ?? false, xiaohongshu_url: record.xiaohongshu_url ?? "", + is_volunteer: record.is_volunteer ?? false, + age_range: record.age_range ?? "", + first_time: record.first_time ?? "", }; + if (record.phone != null && String(record.phone).trim()) { + body.phone = record.phone; + } try { const res = await fetch(url, { diff --git a/app/api/join/volunteers/route.ts b/app/api/join/volunteers/route.ts index ec1d49c..c56a190 100644 --- a/app/api/join/volunteers/route.ts +++ b/app/api/join/volunteers/route.ts @@ -13,7 +13,7 @@ export async function GET() { const base = pb.url.replace(/\/$/, ""); try { - const filter = encodeURIComponent('is_volunteer=true'); + const filter = encodeURIComponent('is_volunteer=true && is_approved=true'); const res = await fetch( `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=20`, { diff --git a/app/components/AddressLink.tsx b/app/components/AddressLink.tsx index addccee..84d70a1 100644 --- a/app/components/AddressLink.tsx +++ b/app/components/AddressLink.tsx @@ -8,12 +8,16 @@ interface AddressLinkProps { className?: string; } +/** 红山地址展示:龙华区 | 🚇红山地铁站 | Loft民宿 */ +const HONGSHAN_DISPLAY = "龙华区 | 🚇 红山地铁站 | Loft民宿"; + /** 可点击的地址,点击打开地图(使用 span 避免嵌套在 Link 内时的 a 标签冲突) */ export default function AddressLink({ place, city, className = "" }: AddressLinkProps) { const isHongshan = place.includes("红山"); const mapUrl = isHongshan - ? `https://www.google.com/maps?q=${HONGSHAN_COORDS.lat},${HONGSHAN_COORDS.lng}` - : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${city}${place}`)}`; + ? `https://api.map.baidu.com/marker?location=${HONGSHAN_COORDS.lat},${HONGSHAN_COORDS.lng}&title=${encodeURIComponent(place)}&output=html` + : `https://map.baidu.com/search?query=${encodeURIComponent(`${city}${place}`)}`; + const displayText = isHongshan ? HONGSHAN_DISPLAY : place; return ( 📍 - {place} + {displayText} ); } diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 8767cdc..c1bf26a 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -31,24 +31,23 @@ export default function HeroSection() { }} /> -
-
+
+
-

+

周末2小时 -
- 认识更多有趣的人 + 认识更多有趣的人

深圳 · 4–8 人小圈子

- 公开场地 · 填表申请 · 茶歇费 · 不尬聊 + 公开场地 · 填表申请 · 茶歇 · 不尬聊

-
+
立即报名 @@ -56,13 +55,13 @@ export default function HeroSection() {
{mainSession && ( -
+
{/* 图片区 - 左侧/上方 */} -
+
{mainSession.coverImage ? ( )}
-
+
{["周末", "4人成行"].map((tag) => ( {tag} @@ -91,33 +90,33 @@ export default function HeroSection() {
{/* 内容区 - 右侧/下方 */} -
-
- -

+

+
+ +

本周场次

-

+

{mainSession.title}

-
-

- 日期 +

+

+ 日期 {mainSession.date}

-

- 时间 +

+ 时间 {mainSession.time}

-

- 地点 +

+ 地点

-
-
-
+
+
+
{(stats?.joiners?.length ? stats.joiners : MOCK_JOINERS).slice(0, 5).map((j, i) => { const nickname = "name" in j ? (j as { name: string }).name : (j as { nickname: string }).nickname; const avatar = "avatar" in j ? (j as { avatar: string }).avatar : (j as { avatar?: string }).avatar; @@ -126,7 +125,7 @@ export default function HeroSection() { return ( { e.preventDefault(); @@ -143,11 +142,11 @@ export default function HeroSection() { ); })}
- + {stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
- + 报名 diff --git a/app/components/HostLink.tsx b/app/components/HostLink.tsx new file mode 100644 index 0000000..b0cd815 --- /dev/null +++ b/app/components/HostLink.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; + +interface HostLinkProps { + host: { id: string; name: string; avatar: string; bio: string; link?: string }; + /** 志愿者招募:无审核通过时显示「立即申请」+ 免费标识 */ + ctaText?: string; + showFreeBadge?: boolean; +} + +/** 移动端优先尝试唤醒小红书 app,PC 新窗口打开 */ +export default function HostLink({ host, ctaText, showFreeBadge }: HostLinkProps) { + const href = "link" in host ? host.link : `/host/${host.id}`; + const isXiaohongshu = typeof href === "string" && (href.includes("xhslink.com") || href.includes("xiaohongshu.com")); + + const handleClick = (e: React.MouseEvent) => { + if (!isXiaohongshu || typeof href !== "string") return; + const isMobile = /Android|iPhone|iPad|iPod|webOS|Mobile/i.test(navigator.userAgent); + if (isMobile) { + e.preventDefault(); + // 移动端:同窗口打开,xhslink.com 会尝试唤起小红书 app,未安装则打开网页 + window.location.assign(href); + } + }; + + if (typeof href !== "string") return null; + + return ( + +
+ {host.name} +
+
+
+

{host.name}

+ {showFreeBadge && ( + + FREE + + )} +
+

{host.bio}

+ + {ctaText ?? "查看主页"} → + +
+ + ); +} diff --git a/app/history/page.tsx b/app/history/page.tsx index 1ecbd67..e0c33dc 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import SessionCoverImage from "../components/SessionCoverImage"; +import AddressLink from "../components/AddressLink"; import Header from "../components/Header"; import Footer from "../components/Footer"; import { PAST_SESSIONS } from "@/config/home.config"; @@ -55,10 +56,10 @@ export default function HistoryPage() { {s.title}

- {s.date} {s.time} · {s.place} + {s.date} {s.time} ·

- 查看详情 → + 查看主页 →
diff --git a/app/join/page.tsx b/app/join/page.tsx index 679b1d5..5629f1f 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -1,50 +1,43 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useState, useEffect, 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"; const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"]; -function getDisplayCount(text?: string, value?: number | string, suffix = ""): string | null { - if (typeof text === "string" && text.trim()) { - return `${text}${suffix}`; - } - if (typeof value === "number" || typeof value === "string") { - return `${value}${suffix}`; - } - return null; -} +/** 微信号仅允许英文字母、数字、下划线、连字符 */ +const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/; -function getGenderLabel(gender?: string): string { - if (gender === "female") return "\u5973"; - if (gender === "male") return "\u7537"; - return "\u65e0"; +interface UserRecord { + hasRecord: boolean; + record: { user_id?: string; amount?: number; order_id?: string; vip?: boolean } | null; + isComplete: boolean; + isWechatFriend: boolean; + isApproved: boolean; + isRejected: boolean; + vip: boolean; } export default function JoinPage() { + const [wechat, setWechat] = useState(""); + const [userRecord, setUserRecord] = useState(null); + const [checkingWechat, setCheckingWechat] = useState(false); + const [xiaohongshuUrl, setXiaohongshuUrl] = useState(""); const [profession, setProfession] = useState(""); - const [wechat, setWechat] = useState(""); const [gender, setGender] = useState(""); const [ageRange, setAgeRange] = useState(""); - const [agreeRules, setAgreeRules] = useState(false); + const [agreeRules, setAgreeRules] = useState(true); const [urlError, setUrlError] = useState(null); const [profile, setProfile] = useState<{ nickname: string; gender?: string; - followers: number | string; - followersText?: string; - following?: number | string; - followingText?: string; - likesAndCollects?: number | string; - likesAndCollectsText?: string; postCount: number; postCountText?: string; - redbookId?: string; ipLocation?: string; - personalLink?: string; avatarUrl?: string; resolvedUrl?: string; } | null>(null); @@ -53,6 +46,48 @@ export default function JoinPage() { const [submitting, setSubmitting] = useState(false); const [qualify, setQualify] = useState(false); const [error, setError] = useState(null); + const [hydrated, setHydrated] = useState(false); + + const [fromPaid, setFromPaid] = useState(() => + typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1" + ); + + const fetchByWechat = async (w: string) => { + if (!w.trim()) return; + setError(null); + setCheckingWechat(true); + try { + const res = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(w.trim())}`); + const data = await res.json(); + setUserRecord({ + hasRecord: !!data.hasRecord, + record: data.record ?? null, + isComplete: !!data.isComplete, + isWechatFriend: !!data.isWechatFriend, + isApproved: !!data.isApproved, + isRejected: !!data.isRejected, + vip: !!data.vip, + }); + } catch { + setUserRecord({ hasRecord: false, record: null, isComplete: false, isWechatFriend: false, isApproved: false, isRejected: false, vip: false }); + } finally { + setCheckingWechat(false); + } + }; + + const handleWechatBlur = () => { + const w = wechat.trim(); + if (!w) { + setUserRecord(null); + return; + } + if (!WECHAT_REGEX.test(w)) { + setError("微信号仅允许英文字母、数字、下划线和连字符"); + return; + } + setError(null); + fetchByWechat(w); + }; const handleUrlBlur = async () => { const url = xiaohongshuUrl.trim(); @@ -61,11 +96,9 @@ export default function JoinPage() { setProfile(null); return; } - setValidating(true); setUrlError(null); setProfile(null); - try { const validateRes = await fetch("/api/xiaohongshu/validate", { method: "POST", @@ -73,34 +106,23 @@ export default function JoinPage() { body: JSON.stringify({ url }), }); const validateData = await validateRes.json(); - if (!validateData.valid) { setUrlError(validateData.error || "小红书link异常"); return; } - const profileRes = await fetch("/api/xiaohongshu/profile", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const profileData = await profileRes.json(); - if (profileData.ok) { setProfile({ nickname: profileData.nickname, gender: profileData.gender, - followers: profileData.followers ?? 0, - followersText: profileData.followersText, - following: profileData.following, - followingText: profileData.followingText, - likesAndCollects: profileData.likesAndCollects, - likesAndCollectsText: profileData.likesAndCollectsText, postCount: profileData.postCount ?? 0, postCountText: profileData.postCountText, - redbookId: profileData.redbookId, ipLocation: profileData.ipLocation, - personalLink: profileData.personalLink, avatarUrl: profileData.avatarUrl, resolvedUrl: profileData.resolvedUrl, }); @@ -117,7 +139,6 @@ export default function JoinPage() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); - if (xiaohongshuUrl.trim() && urlError) { setError("请先修正小红书链接"); return; @@ -130,43 +151,22 @@ export default function JoinPage() { setError("请填写微信号"); return; } + if (!WECHAT_REGEX.test(wechat)) { + setError("微信号仅允许英文字母、数字、下划线和连字符"); + return; + } if (!gender) { setError("请选择性别"); return; } - if (!ageRange) { - setError("请选择年龄区间"); - return; - } - if (!agreeRules) { - setError("请同意社区规则"); - return; - } - setSubmitting(true); try { const formGenderFemale = gender === "女" || gender === "female"; - const xiaohongshuGenderFemale = profile?.gender === "female"; const postCountOk = (profile?.postCount ?? 0) > 10; - const ipInGuangdong = profile?.ipLocation?.includes("广东") ?? false; - const qualify = - formGenderFemale && - xiaohongshuGenderFemale && - postCountOk && - ipInGuangdong; - + const qualify = formGenderFemale && postCountOk; const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无"; - const postCountLabel = getDisplayCount(profile?.postCountText, profile?.postCount) ?? "0"; const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || ""; - const submitReasonParts = [ - `职业:${profession.trim()}`, - `性别:${genderLabel}`, - `年龄:${ageRange}`, - profile && `发帖:${postCountLabel}`, - profile?.ipLocation && `IP:${profile.ipLocation}`, - ].filter(Boolean); - const res = await fetch("/api/join", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -178,7 +178,7 @@ export default function JoinPage() { xiaohongshu_url: submitXiaohongshuUrl, xiaohongshu_avatar_url: profile?.avatarUrl || "", gender: genderLabel, - intro: submitReasonParts.join(" | "), + intro: "", age_range: ageRange, city: "深圳", session: "digital-nomad", @@ -194,11 +194,9 @@ export default function JoinPage() { }), }); const data = await res.json(); - if (!res.ok || !data?.ok) { throw new Error(data?.error || "提交失败,请稍后重试"); } - setQualify(qualify); setSubmitted(true); } catch (err) { @@ -208,60 +206,46 @@ export default function JoinPage() { } }; - const followingDisplay = getDisplayCount(profile?.followingText, profile?.following); - const followersDisplay = getDisplayCount(profile?.followersText, profile?.followers); - const likesDisplay = getDisplayCount(profile?.likesAndCollectsText, profile?.likesAndCollects); - const safePostCountDisplay = getDisplayCount(profile?.postCountText, profile?.postCount, " \u7bc7"); + const handlePayNow = () => { + 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), + }); + }; - const profileGenderLabel = getGenderLabel(profile?.gender); + useEffect(() => { + setHydrated(true); + }, []); - if (submitted) { + // 成功报名:已支付 或 支付成功跳转 或 (好友且vip) + const showSuccess = fromPaid || (userRecord?.hasRecord && userRecord.isComplete) || (userRecord?.hasRecord && userRecord.isWechatFriend && userRecord.vip); + + // 有数据、非好友 → 只展示状态页:审核中/审核通过/审核拒绝 + const showStatusPage = userRecord?.hasRecord && !userRecord.isWechatFriend; + + // 有数据、好友、非vip、未支付 → 支付状态页 + const showPayPage = userRecord?.hasRecord && userRecord.isWechatFriend && !userRecord.vip && !userRecord.isComplete; + + if (submitted || showSuccess) { return (
- {qualify ? ( - <> -
- 🎉 -
-

- 审核通过 -

-

- 扫码添加好友,加入活动群 -

-
- 微信二维码 -
-

- 📝 添加时请备注:活动报名 -

- - ) : ( - <> -
- ⏳ -
-

- 资料审核中 -

-

- ⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。 -

- - )} -

- 💰 本场收取少量场地分摊费29/人,用于覆盖民宿/场地基础成本。
- 通过审核后再确认名额,未确认无需支付。 +

🎉
+

报名成功

+

感谢报名,活动前会联系你确认~

+

+ {`本场收取少量场地分摊费29/人 +用于覆盖民宿/场地基础成本。 +通过审核后再确认名额,未确认无需支付。`}

- + 🏠 返回首页
@@ -269,81 +253,116 @@ export default function JoinPage() { ); } - return ( -
-
-
- - ← 返回 + if (showStatusPage) { + const status = userRecord!.isRejected ? "rejected" : userRecord!.isApproved ? "approved" : "pending"; + return ( +
+
+ {status === "approved" ? ( + <> +
🎉
+

审核通过

+

扫码添加好友,加入活动群

+
+ 微信二维码 +
+

📝 添加时请备注:活动报名

+ + ) : status === "rejected" ? ( + <> +
+

审核拒绝

+

很抱歉,本次申请未通过审核。如有疑问可联系发起人。

+ + ) : ( + <> +
+

审核中

+

⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。

+ + )} +

+ {`本场收取少量场地分摊费29/人 +用于覆盖民宿/场地基础成本。 +通过审核后再确认名额,未确认无需支付。`} +

+ + + 🏠 返回首页 -
-
+
+ ); + } -
-
-
-
-
🌍
-
+ if (showPayPage) { + return ( +
+
+
+ ← 返回 +
+
+
+
+
💰
+

支付场地茶歇费

+

29元/人,用于覆盖民宿/场地基础成本

+ + +
+
+
+ ); + } -
-
-
- -

- 💡 建议填写,可加快审核 -

- { - setXiaohongshuUrl(e.target.value); - setUrlError(null); - }} - onBlur={handleUrlBlur} - placeholder="https://www.xiaohongshu.com/user/profile/xxx" - className="form-input" - /> - {validating && ( -

🔄 校验中…

- )} - {urlError && !validating && ( -

⚠️ {urlError}

- )} - {profile && !urlError && ( -

- ✅ 已获取:{profile.nickname} - {profile.redbookId && ` \u00b7 @${profile.redbookId}`} - {profile.ipLocation && ` \u00b7 ${profile.ipLocation}`} - {profile.personalLink && ` \u00b7 ${profile.personalLink}`} - {followingDisplay && ` \u00b7 \u5173\u6ce8 ${followingDisplay}`} - {followersDisplay && ` \u00b7 \u7c89\u4e1d ${followersDisplay}`} - {likesDisplay && ` \u00b7 \u83b7\u8d5e\u4e0e\u6536\u85cf ${likesDisplay}`} - {safePostCountDisplay && ` \u00b7 \u53d1\u5e16 ${safePostCountDisplay}`} - {` \u00b7 ${profileGenderLabel}`} -

- )} + if (!hydrated) { + return ( +
+ 加载中… +
+ ); + } + + // 微信号输入区:失焦时查询,查询中不展示其他元素 + const wechatOnlyView = !userRecord || checkingWechat; + + if (wechatOnlyView) { + return ( +
+
+
+ ← 返回 + +
+
+
+
+
+
+
- -
- - setProfession(e.target.value)} - required - className="form-input" - /> -
- +
+
+ {checkingWechat && ( +

🔄 查询中…

+ )} + {error && ( +

⚠️ {error}

+ )} + +

填写微信号并完成查询后显示完整表单

+
+
+
+ 🏠 返回首页 +
+
+
+ ); + } + + // 无记录 → 显示完整表单 + return ( +
+
+
+ ← 返回 + +
+
+
+
+
+
+
+
+
+
+ +
+ + setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))} + onBlur={handleWechatBlur} required className="form-input" />
- -
-
- - -
-
- - -
-
-
+ +

💡 建议填写,可加快审核

+ { setXiaohongshuUrl(e.target.value); setUrlError(null); }} + onBlur={handleUrlBlur} + placeholder="https://www.xiaohongshu.com/user/profile/xxx" + className="form-input" + /> + {validating &&

🔄 校验中…

} + {urlError && !validating &&

⚠️ {urlError}

} + {profile && !urlError &&

已验证

} +
+
+ + setProfession(e.target.value)} required className="form-input" /> +
+
+ + +
+
+ +
+
- {error && ( -

- ⚠️ {error} -

+

⚠️ {error}

)} - -
-
- - 🏠 返回首页 - + + | + 🏠 返回首页
diff --git a/app/join/paid/page.tsx b/app/join/paid/page.tsx index 1a2fd10..d26d3d0 100644 --- a/app/join/paid/page.tsx +++ b/app/join/paid/page.tsx @@ -99,14 +99,14 @@ export default function JoinPaidPage() { if (completeStatus !== "success") return; setCountdown(COUNTDOWN_SECONDS); const timer = setInterval(() => { - setCountdown((prev) => { - if (prev == null || prev <= 1) { - clearInterval(timer); - window.location.replace("/"); - return 0; - } - return prev - 1; - }); + setCountdown((prev) => { + if (prev == null || prev <= 1) { + clearInterval(timer); + window.location.replace("/join?paid=1"); + return 0; + } + return prev - 1; + }); }, 1000); return () => clearInterval(timer); }, [completeStatus]); @@ -123,7 +123,7 @@ export default function JoinPaidPage() {

{completeStatus === "success" ? countdown != null - ? `${countdown} 秒后返回首页` + ? `${countdown} 秒后跳转报名页` : "感谢您的支持" : completeStatus === "error" ? "请稍后刷新页面重试" @@ -142,10 +142,10 @@ export default function JoinPaidPage() { )} - ← 返回首页 + ← 查看报名状态

diff --git a/app/page.tsx b/app/page.tsx index d70a92e..0c0ad97 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import Image from "next/image"; import HeroSection from "./components/HeroSection"; import SessionCard from "./components/SessionCard"; +import HostLink from "./components/HostLink"; import Footer from "./components/Footer"; import Header from "./components/Header"; import { getSessionsWithDates, HOSTS } from "@/config/home.config"; @@ -58,18 +59,18 @@ export default function Home() { 📋 怎么参加
-
+
-
1
-

填表

+
1
+

填表

-
2
-

审核

+
2
+

审核

-
3
-

通过后联系你确认

+
3
+

通过后联系你确认

@@ -80,7 +81,7 @@ export default function Home() { 📍 公开场地 👥 4–8 人 ✍️ 填表申请 - ☕ 茶歇费 + ☕ 茶歇
{/* 本周场次 */} @@ -127,28 +128,14 @@ export default function Home() {
{HOSTS.map((host) => ( - -
- {host.name} -
-
-

- {host.name} -

-

- {host.bio} -

- - 查看详情 → - -
- + host={host} + {...(host.id === "host-2" && volunteers.length === 0 && { + ctaText: "立即申请", + showFreeBadge: true, + })} + /> ))}
{volunteers.length > 0 && ( @@ -167,7 +154,12 @@ export default function Home() {
)}
- {v.nickname} +
+ {v.nickname} + {v.xiaohongshu_url && ( + 查看主页 + )} +
); return v.xiaohongshu_url ? ( @@ -221,11 +213,11 @@ export default function Home() {
- {/* CTA */} -
+ {/* CTA - 移动端隐藏,只保留底部悬浮去报名 */} +
立即报名 diff --git a/app/volunteer/page.tsx b/app/volunteer/page.tsx index 3789e6c..d6b844c 100644 --- a/app/volunteer/page.tsx +++ b/app/volunteer/page.tsx @@ -5,12 +5,7 @@ import Link from "next/link"; import ThemeToggle from "../components/ThemeToggle"; const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"]; - -function getDisplayCount(text?: string, value?: number | string, suffix = ""): string | null { - if (typeof text === "string" && text.trim()) return `${text}${suffix}`; - if (typeof value === "number" || typeof value === "string") return `${value}${suffix}`; - return null; -} +const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/; export default function VolunteerPage() { const [xiaohongshuUrl, setXiaohongshuUrl] = useState(""); @@ -92,6 +87,10 @@ export default function VolunteerPage() { setError("请填写微信号"); return; } + if (!WECHAT_REGEX.test(wechat)) { + setError("微信号仅允许英文字母、数字、下划线和连字符"); + return; + } if (!gender) { setError("请选择性别"); return; @@ -107,15 +106,7 @@ export default function VolunteerPage() { setSubmitting(true); try { const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无"; - const postCountLabel = getDisplayCount(profile?.postCountText, profile?.postCount) ?? "0"; const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || ""; - const submitReasonParts = [ - `[志愿者]`, - `职业:${profession.trim()}`, - `性别:${genderLabel}`, - `年龄:${ageRange}`, - profile && `发帖:${postCountLabel}`, - ].filter(Boolean); const res = await fetch("/api/join", { method: "POST", @@ -128,7 +119,7 @@ export default function VolunteerPage() { xiaohongshu_url: submitXiaohongshuUrl, xiaohongshu_avatar_url: profile?.avatarUrl || "", gender: genderLabel, - intro: submitReasonParts.join(" | "), + intro: "", age_range: ageRange, city: "深圳", session: "volunteer", @@ -227,7 +218,7 @@ export default function VolunteerPage() { {urlError && !validating &&

⚠️ {urlError}

} {profile && !urlError && (

- ✅ 已获取:{profile.nickname} + 已验证

)}
@@ -256,7 +247,7 @@ export default function VolunteerPage() { maxLength={140} placeholder="用于联系" value={wechat} - onChange={(e) => setWechat(e.target.value)} + onChange={(e) => setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))} required className="form-input" /> diff --git a/config/services.config.ts b/config/services.config.ts index 481de6a..2a4baea 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -58,9 +58,12 @@ export function getPaymentConfig(): PaymentConfig { }; } +/** 茶歇费 29元/人 */ +export const JOIN_TEA_FEE = 2900; + export function getJoinPaymentConfig(): { amount: number; type: string } { return { - amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || MEETUP_JOIN_AMOUNT, + amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || JOIN_TEA_FEE, type: "salon", }; }