From c54d46517c1354d6cdd6a778716694bed8ad1b82 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 16 Mar 2026 05:41:47 -0500 Subject: [PATCH] 's' --- README.md | 2 +- app/api/join/route.ts | 29 +-- app/api/join/stats/route.ts | 4 +- app/api/join/volunteers/route.ts | 40 ++++ app/components/AddressLink.tsx | 8 +- app/components/HeroSection.tsx | 144 +++++++----- app/components/SessionCard.tsx | 19 +- app/host/[id]/page.tsx | 34 ++- app/join/page.tsx | 239 +++++++------------- app/lib/dates.ts | 83 +++++++ app/page.tsx | 84 +++++-- app/volunteer/page.tsx | 339 ++++++++++++++++++++++++++++ config/home.config.ts | 51 +++-- public/images/11.webp | Bin 0 -> 17446 bytes public/images/QR.png | Bin 0 -> 111312 bytes public/images/volunteer-mystery.svg | 6 + 16 files changed, 796 insertions(+), 286 deletions(-) create mode 100644 app/api/join/volunteers/route.ts create mode 100644 app/lib/dates.ts create mode 100644 app/volunteer/page.tsx create mode 100644 public/images/11.webp create mode 100644 public/images/QR.png create mode 100644 public/images/volunteer-mystery.svg diff --git a/README.md b/README.md index c8b10db..1f8e175 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 +- **PocketBase 需创建 `solanRed` 集合**(可克隆自 solan),字段:user_id, nickname, occupation, reason, wechatId, gender, education, gradYear, phone, single, media, type, order_id, amount, is_volunteer(布尔), xiaohongshu_url ## 启动 diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 84b257f..05cfa2f 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -45,6 +45,7 @@ export async function POST(request: NextRequest) { const whyJoin = String(formData.why_join || "").trim(); 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; @@ -52,39 +53,22 @@ export async function POST(request: NextRequest) { const isDigitalNomad = session === "digital-nomad"; const contact = wechatOrPhone || wechat || phone; - if (!nickname || !intro || !session || !agreeRules || !xiaohongshuUrl) { + if (!nickname || !session || !agreeRules) { return NextResponse.json( - { ok: false, error: "请填写必填项(含小红书主页地址、自我介绍)" }, + { ok: false, error: "请填写必填项" }, { status: 400 } ); } if (!contact) { return NextResponse.json( - { ok: false, error: "请填写微信号或手机号" }, - { status: 400 } - ); - } - if (isDigitalNomad && (!education || !graduationYear)) { - return NextResponse.json( - { ok: false, error: "请选择学历和毕业时间" }, + { ok: false, error: "请填写微信号" }, { status: 400 } ); } const userId = getUserIdFromCookie(request) || generateAnonId(); - const EDUCATION_GRAD_AGE: Record = { - "高中及以下": 18, - 大专: 21, - 本科: 22, - 硕士: 25, - 博士: 28, - }; - const gradAge = EDUCATION_GRAD_AGE[education] ?? 22; - const estimatedAge = new Date().getFullYear() - parseInt(graduationYear, 10) + gradAge; - const ageRangeForRecord = isDigitalNomad && education && graduationYear - ? `推算约${estimatedAge}岁(学历${education}+${graduationYear}毕业)` - : ageRange; + const ageRangeForRecord = ageRange || ""; const record = { user_id: userId, @@ -104,6 +88,7 @@ export async function POST(request: NextRequest) { amount: 0, age_range: ageRangeForRecord, first_time: firstTime, + is_volunteer: isVolunteer, }; try { @@ -167,6 +152,8 @@ async function tryPocketBaseDirect( type: record.type ?? "", order_id: record.order_id ?? "", amount: record.amount ?? 0, + is_volunteer: record.is_volunteer ?? false, + xiaohongshu_url: record.xiaohongshu_url ?? "", }; try { diff --git a/app/api/join/stats/route.ts b/app/api/join/stats/route.ts index 2c008cf..0cba2df 100644 --- a/app/api/join/stats/route.ts +++ b/app/api/join/stats/route.ts @@ -37,9 +37,9 @@ export async function GET(request: NextRequest) { const total = typeof data?.totalItems === "number" ? data.totalItems : items.length; stats[s.id] = { count: total, - joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string }) => ({ + joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({ nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名", - avatar: String(r?.xiaohongshu_avatar || "").trim() || undefined, + avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined, xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined, })), }; diff --git a/app/api/join/volunteers/route.ts b/app/api/join/volunteers/route.ts new file mode 100644 index 0000000..ec1d49c --- /dev/null +++ b/app/api/join/volunteers/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { getPocketBaseConfig } from "@/config/services.config"; + +/** 获取志愿者列表 - solanRed 中 is_volunteer=true 的记录 */ +export async function GET() { + const token = await getAdminToken(); + if (!token) { + return NextResponse.json({ ok: true, volunteers: [] }, { status: 200 }); + } + + const pb = getPocketBaseConfig(); + const base = pb.url.replace(/\/$/, ""); + + try { + const filter = encodeURIComponent('is_volunteer=true'); + const res = await fetch( + `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=20`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + } + ); + if (!res.ok) { + return NextResponse.json({ ok: true, volunteers: [] }, { status: 200 }); + } + const data = await res.json(); + const items = Array.isArray(data?.items) ? data.items : []; + const volunteers = items.map( + (r: { nickname?: string; xiaohongshu_nickname?: string; media?: string; xiaohongshu_url?: string }) => ({ + nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "志愿者", + avatar: String(r?.media || "").trim() || undefined, + xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined, + }) + ); + return NextResponse.json({ ok: true, volunteers }); + } catch { + return NextResponse.json({ ok: true, volunteers: [] }, { status: 200 }); + } +} diff --git a/app/components/AddressLink.tsx b/app/components/AddressLink.tsx index ae75833..addccee 100644 --- a/app/components/AddressLink.tsx +++ b/app/components/AddressLink.tsx @@ -1,5 +1,7 @@ "use client"; +import { HONGSHAN_COORDS } from "@/config/home.config"; + interface AddressLinkProps { place: string; city: string; @@ -8,8 +10,10 @@ interface AddressLinkProps { /** 可点击的地址,点击打开地图(使用 span 避免嵌套在 Link 内时的 a 标签冲突) */ export default function AddressLink({ place, city, className = "" }: AddressLinkProps) { - const query = encodeURIComponent(`${city}${place}`); - const mapUrl = `https://www.google.com/maps/search/?api=1&query=${query}`; + 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}`)}`; return ( (null); useEffect(() => { @@ -34,96 +35,121 @@ export default function HeroSection() {

- 周末留两小时 + 周末2小时
- 认识几位同频的人 + 认识更多有趣的人

- 深圳 · 6–8 人小圈子 + 深圳 · 4–8 人小圈子

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

- 填表申请本周场次 + 立即报名
{mainSession && ( -
+
-
+ {/* 图片区 - 左侧/上方 */} +
{mainSession.coverImage ? ( ) : ( -
+
)} -
- - 周末下午 - - - 6–8 人 - - - 公开场地 - +
+
+ {["周末", "4人成行"].map((tag) => ( + + {tag} + + ))}
-
-

- 本周场次 -

-

{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; - const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url; - const showAvatar = avatar && avatar.length > 0; - return ( - e.preventDefault()} - > - {showAvatar ? ( - - ) : ( - {nickname.slice(0, 1) || "?"} - )} - - ); - })} + + {/* 内容区 - 右侧/下方 */} +
+
+ +

+ 本周场次 +

+
+

+ {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; + const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url; + const showAvatar = avatar && avatar.length > 0; + return ( + { + e.preventDefault(); + e.stopPropagation(); + if (xhUrl) window.open(xhUrl, "_blank", "noopener,noreferrer"); + }} + > + {showAvatar ? ( + + ) : ( + {nickname.slice(0, 1) || "?"} + )} + + ); + })} +
+ + {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} +
- - {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} + + 报名 +
diff --git a/app/components/SessionCard.tsx b/app/components/SessionCard.tsx index f430730..c3ff80c 100644 --- a/app/components/SessionCard.tsx +++ b/app/components/SessionCard.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import Link from "next/link"; import SessionCoverImage from "./SessionCoverImage"; import AddressLink from "./AddressLink"; -import { SESSIONS, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config"; +import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config"; interface JoinStats { count: number; @@ -18,7 +18,7 @@ export default function SessionCard({ session, index, }: { - session: (typeof SESSIONS)[0]; + session: (ReturnType)[0]; index: number; }) { const [stats, setStats] = useState(null); @@ -35,7 +35,7 @@ export default function SessionCard({ href={`/join?session=${encodeURIComponent(session.id)}`} className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 transition-all" > -
+
{session.coverImage ? ( e.preventDefault()} + onClick={(e) => { + if (xhUrl) { + e.preventDefault(); + e.stopPropagation(); + window.open(xhUrl, "_blank", "noopener,noreferrer"); + } else { + e.preventDefault(); + } + }} > {showAvatar ? ( diff --git a/app/host/[id]/page.tsx b/app/host/[id]/page.tsx index 4374997..b00d0a3 100644 --- a/app/host/[id]/page.tsx +++ b/app/host/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect } from "react"; import Image from "next/image"; import Link from "next/link"; import Header from "../../components/Header"; @@ -9,9 +10,20 @@ import { HOSTS } from "@/config/home.config"; export default function HostDetailPage() { const params = useParams(); + const router = useRouter(); const id = typeof params.id === "string" ? params.id : ""; const host = HOSTS.find((h) => h.id === id); + useEffect(() => { + if (host && "link" in host && host.link) { + if (host.link.startsWith("http")) { + window.location.href = host.link; + } else { + router.replace(host.link); + } + } + }, [host, router]); + if (!host) { return (
@@ -25,6 +37,14 @@ export default function HostDetailPage() { ); } + if ("link" in host && host.link) { + return ( +
+

跳转中…

+
+ ); + } + return (
@@ -54,11 +74,13 @@ export default function HostDetailPage() {

{host.bio}

-
-

- {host.intro} -

-
+ {"intro" in host && host.intro && ( +
+

+ {host.intro} +

+
+ )}
diff --git a/app/join/page.tsx b/app/join/page.tsx index a50c598..679b1d5 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -5,16 +5,7 @@ import Link from "next/link"; import ThemeToggle from "../components/ThemeToggle"; import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config"; -/** 各学历对应的典型毕业年龄,用于推算年龄 */ -const EDUCATION_GRAD_AGE: Record = { - "高中及以下": 18, - "大专": 21, - "本科": 22, - "硕士": 25, - "博士": 28, -}; -const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"]; -const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i)); +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()) { @@ -36,10 +27,8 @@ export default function JoinPage() { const [xiaohongshuUrl, setXiaohongshuUrl] = useState(""); const [profession, setProfession] = useState(""); const [wechat, setWechat] = useState(""); - const [phone, setPhone] = useState(""); - const [intro, setIntro] = useState(""); - const [education, setEducation] = useState(""); - const [graduationYear, setGraduationYear] = useState(""); + const [gender, setGender] = useState(""); + const [ageRange, setAgeRange] = useState(""); const [agreeRules, setAgreeRules] = useState(false); const [urlError, setUrlError] = useState(null); const [profile, setProfile] = useState<{ @@ -129,28 +118,24 @@ export default function JoinPage() { e.preventDefault(); setError(null); - if (!xiaohongshuUrl.trim()) { - setError("请填写小红书主页链接"); - return; - } - if (urlError) { + if (xiaohongshuUrl.trim() && urlError) { setError("请先修正小红书链接"); return; } - if (!education || !graduationYear) { - setError("请选择学历和毕业时间"); - return; - } if (!profession.trim()) { setError("请填写职业"); return; } - if (!wechat.trim() && !phone.trim()) { - setError("请填写微信号或手机号"); + if (!wechat.trim()) { + setError("请填写微信号"); return; } - if (!intro.trim()) { - setError("请填写自我介绍"); + if (!gender) { + setError("请选择性别"); + return; + } + if (!ageRange) { + setError("请选择年龄区间"); return; } if (!agreeRules) { @@ -160,29 +145,26 @@ export default function JoinPage() { setSubmitting(true); try { - const gradAge = EDUCATION_GRAD_AGE[education] ?? 22; - const estimatedAge = new Date().getFullYear() - parseInt(graduationYear, 10) + gradAge; - const ageOk = estimatedAge < 28; - const isFemale = profile?.gender === "female"; - const genderLabel = getGenderLabel(profile?.gender); - const postCountOk = (profile?.postCount ?? 0) > 5; - const postCountLabel = getDisplayCount(profile?.postCountText, profile?.postCount) ?? "0"; - const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim(); + 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 reasonParts = [ - intro, - `学历:${education}`, - `毕业:${graduationYear}`, - profile?.gender && `性别:${profile.gender}`, - `发帖:${profile?.postCount ?? 0}`, - ].filter(Boolean); + const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无"; + const postCountLabel = getDisplayCount(profile?.postCountText, profile?.postCount) ?? "0"; + const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || ""; const submitReasonParts = [ - reasonParts[0], - `\u5b66\u5386:${education}`, - `\u6bd5\u4e1a:${graduationYear}`, - `\u6027\u522b:${genderLabel}`, - `\u53d1\u5e16:${postCountLabel}`, + `职业:${profession.trim()}`, + `性别:${genderLabel}`, + `年龄:${ageRange}`, + profile && `发帖:${postCountLabel}`, + profile?.ipLocation && `IP:${profile.ipLocation}`, ].filter(Boolean); const res = await fetch("/api/join", { @@ -191,16 +173,17 @@ export default function JoinPage() { body: JSON.stringify({ nickname: profile?.nickname || "匿名", wechat: wechat.trim(), - phone: phone.trim(), + phone: "", profession: profession.trim(), xiaohongshu_url: submitXiaohongshuUrl, xiaohongshu_avatar_url: profile?.avatarUrl || "", gender: genderLabel, intro: submitReasonParts.join(" | "), + age_range: ageRange, city: "深圳", session: "digital-nomad", - education, - graduation_year: graduationYear, + education: "", + graduation_year: "", agree_rules: true, first_time: "", status: "", @@ -216,7 +199,7 @@ export default function JoinPage() { throw new Error(data?.error || "提交失败,请稍后重试"); } - setQualify(isFemale && ageOk && postCountOk); + setQualify(qualify); setSubmitted(true); } catch (err) { setError(err instanceof Error ? err.message : "网络错误,请重试"); @@ -229,7 +212,6 @@ export default function JoinPage() { const followersDisplay = getDisplayCount(profile?.followersText, profile?.followers); const likesDisplay = getDisplayCount(profile?.likesAndCollectsText, profile?.likesAndCollects); const safePostCountDisplay = getDisplayCount(profile?.postCountText, profile?.postCount, " \u7bc7"); - const postCountDisplay = getDisplayCount(profile?.postCountText, profile?.postCount, " 篇"); const profileGenderLabel = getGenderLabel(profile?.gender); @@ -240,13 +222,13 @@ export default function JoinPage() { {qualify ? ( <>
- ✅ + 🎉

- 恭喜,符合条件 + 审核通过

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

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

) : ( @@ -268,15 +250,19 @@ export default function JoinPage() { 资料审核中

- 我们会在 1-3 个工作日内审核您的资料,通过后将通过小红书私信联系您。 + ⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。

)} +

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

- ← 返回首页 + 🏠 返回首页
@@ -306,8 +292,11 @@ export default function JoinPage() {
+

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

{validating && ( -

校验中…

+

🔄 校验中…

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

{urlError}

+

⚠️ {urlError}

)} {profile && !urlError && (

- {"\u2713 \u5df2\u83b7\u53d6\uff1a"}{profile.nickname} + ✅ 已获取:{profile.nickname} {profile.redbookId && ` \u00b7 @${profile.redbookId}`} {profile.ipLocation && ` \u00b7 ${profile.ipLocation}`} {profile.personalLink && ` \u00b7 ${profile.personalLink}`} @@ -339,24 +327,11 @@ export default function JoinPage() { {` \u00b7 ${profileGenderLabel}`}

)} - {((profile) => false ? ( -

- ✓ 已获取:{profile.nickname} - {profile.redbookId && ` · @${profile.redbookId}`} - {profile.ipLocation && ` · ${profile.ipLocation}`} - {profile.personalLink && ` · ${profile.personalLink}`} - {profile.following != null && ` · 关注 ${profile.following}`} - {profile.followers != null && ` · 粉丝 ${profile.followers}`} - {profile.likesAndCollects != null && ` · 获赞与收藏 ${profile.likesAndCollects}`} - {profile.postCount != null && ` · 发帖 ${profile.postCount} 篇`} - {profile.gender && ` · ${profile.gender === "female" ? "女" : "男"}`} -

- ) : null)(profile as NonNullable)}
-
-
- - setWechat(e.target.value)} - className="form-input" - /> -
-
- - setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))} - className="form-input" - /> -
-
-

- 微信号和手机号至少填写一项 -

-
-