diff --git a/app/api/join/route.ts b/app/api/join/route.ts index af2f248..7087fb3 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -29,6 +29,7 @@ export async function POST(request: NextRequest) { const session = String(formData.session || "").trim(); const ageRange = String(formData.age_range || "").trim(); const wechatOrPhone = String(formData.wechat_or_phone || "").trim(); + const xiaohongshuUrl = String(formData.xiaohongshu_url || "").trim(); let intro = String(formData.intro || "").trim(); const firstTime = String(formData.first_time || "").trim(); const agreeRules = !!formData.agree_rules; @@ -42,9 +43,9 @@ export async function POST(request: NextRequest) { intro = extra; } - if (!nickname || !wechatOrPhone || !intro || !session || !agreeRules) { + if (!nickname || !wechatOrPhone || !intro || !session || !agreeRules || !xiaohongshuUrl) { return NextResponse.json( - { ok: false, error: "请填写必填项" }, + { ok: false, error: "请填写必填项(含小红书主页地址)" }, { status: 400 } ); } @@ -57,6 +58,7 @@ export async function POST(request: NextRequest) { occupation: city, reason: intro, wechatId: wechatOrPhone, + xiaohongshu_url: xiaohongshuUrl, gender: "", education: "", gradYear: "", @@ -114,11 +116,13 @@ 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: [record.reason, record.age_range, record.first_time].filter(Boolean).join(" | "), + reason: reasonParts.filter(Boolean).join(" | "), wechatId: record.wechatId ?? "", gender: record.gender ?? "", education: record.education ?? "", diff --git a/app/api/join/stats/route.ts b/app/api/join/stats/route.ts new file mode 100644 index 0000000..2c008cf --- /dev/null +++ b/app/api/join/stats/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { SESSIONS } from "@/config/home.config"; + +/** 按场次返回报名人数和最近报名者(昵称),头像由前端用 initials 或占位 */ +export async function GET(request: NextRequest) { + const token = await getAdminToken(); + if (!token) { + return NextResponse.json( + { ok: true, stats: getEmptyStats() }, + { status: 200 } + ); + } + + const pb = getPocketBaseConfig(); + const base = pb.url.replace(/\/$/, ""); + + const stats: Record = {}; + + for (const s of SESSIONS) { + try { + const filter = encodeURIComponent(`type="${s.id}"`); + const res = await fetch( + `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + } + ); + if (!res.ok) { + stats[s.id] = { count: 0, joiners: [] }; + continue; + } + const data = await res.json(); + const items = Array.isArray(data?.items) ? data.items : []; + 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 }) => ({ + nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名", + avatar: String(r?.xiaohongshu_avatar || "").trim() || undefined, + xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined, + })), + }; + } catch { + stats[s.id] = { count: 0, joiners: [] }; + } + } + + return NextResponse.json({ ok: true, stats }); +} + +function getEmptyStats() { + const stats: Record = {}; + for (const s of SESSIONS) { + stats[s.id] = { count: 0, joiners: [] }; + } + return stats; +} diff --git a/app/coming-soon/page.tsx b/app/coming-soon/page.tsx new file mode 100644 index 0000000..036608e --- /dev/null +++ b/app/coming-soon/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import Link from "next/link"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; + +export default function ComingSoonPage() { + return ( +
+
+
+
+

+

+ 功能开发中 +

+

+ 敬请期待~ +

+ + 返回首页 + +
+
+
+
+ ); +} diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index b3f3d5a..48f866d 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,8 +1,15 @@ +import Link from "next/link"; + export default function Footer() { return (
-

© {new Date().getFullYear()} Salon. 深圳·广州·惠州 同城活动。

+
+ + 历史活动 + +
+

© {new Date().getFullYear()} Salon · 深圳 周末轻社交

); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 662ed8a..edfb51c 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -10,7 +10,12 @@ export default function Header() { Salon - + ); diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 3f69946..eab4a0c 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -1,11 +1,24 @@ "use client"; +import { useEffect, useState } from "react"; +import Image from "next/image"; import Link from "next/link"; import SessionCoverImage from "./SessionCoverImage"; -import { SESSIONS } from "@/config/home.config"; +import { SESSIONS, RECENT_JOINERS } from "@/config/home.config"; + +const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3); export default function HeroSection() { const mainSession = SESSIONS[0]; + const [stats, setStats] = useState<{ count: number; joiners: { nickname: string }[] } | null>(null); + + useEffect(() => { + if (!mainSession) return; + fetch("/api/join/stats") + .then((r) => r.json()) + .then((d) => d?.stats?.[mainSession.id] && setStats(d.stats[mainSession.id])) + .catch(() => {}); + }, [mainSession?.id]); return (
周末留两小时
- 去一个舒服的公开场地,认识几位同频的人 + 去loft民宿,认识几位同频的人

- 深圳 · 广州 · 惠州 · 周末 6–8 人轻社交小组 + 深圳 · 6–8 人小圈子

- 咖啡馆 / 书店 / citywalk · 6–8 人小组 · 公开场地 · 填表申请 · 联系方式仅用于活动确认 · 不收费 + 公开场地 · 填表申请 · 免费 · 不尬聊

-
+
填表申请本周场次 - - 先看规则 -
@@ -90,6 +97,34 @@ export default function HeroSection() { 报名这场 → +
+
+ {(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) || "?"} + )} + + ); + })} +
+ + {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} + +
diff --git a/app/components/SessionCard.tsx b/app/components/SessionCard.tsx new file mode 100644 index 0000000..dfc4920 --- /dev/null +++ b/app/components/SessionCard.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import SessionCoverImage from "./SessionCoverImage"; +import { SESSIONS, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config"; + +interface JoinStats { + count: number; + joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[]; +} + +const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3); + +export default function SessionCard({ + session, + index, +}: { + session: (typeof SESSIONS)[0]; + index: number; +}) { + const [stats, setStats] = useState(null); + + useEffect(() => { + fetch("/api/join/stats") + .then((r) => r.json()) + .then((d) => d?.stats?.[session.id] && setStats(d.stats[session.id])) + .catch(() => {}); + }, [session.id]); + + return ( + +
+ {session.coverImage ? ( + + ) : ( +
+ {session.type === "skill-exchange" ? "🔄" : "☕"} +
+ )} +
+
+
+ {session.tags?.map((tag) => ( + + {SESSION_TAGS[tag]} + + ))} + {index === 0 && ( + + 主推 + + )} +
+ + {ACTIVITY_TYPES[session.type]} · {session.city} + +

+ {session.title} +

+

+ {session.date} {session.time} · {session.place} +

+ + 报名这场 → + +
+
+ {(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) || "?"} + )} + + ); + })} +
+ + {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} + +
+
+ + ); +} diff --git a/app/components/SessionCoverImage.tsx b/app/components/SessionCoverImage.tsx index 759f5f6..a6e1622 100644 --- a/app/components/SessionCoverImage.tsx +++ b/app/components/SessionCoverImage.tsx @@ -6,7 +6,7 @@ import Image from "next/image"; interface SessionCoverImageProps { src: string; alt: string; - type?: "skill-exchange" | "side-hustle" | "cafe" | "bookstore" | "citywalk" | "light-chat"; + type?: "skill-exchange" | "side-hustle" | "loft" | "light-chat"; className?: string; fill?: boolean; sizes?: string; @@ -18,7 +18,7 @@ export default function SessionCoverImage({ src, alt, type, className = "", fill if (error) { return (
- {type === "bookstore" ? "📚" : type === "citywalk" ? "🚶" : type === "skill-exchange" ? "🔄" : "☕"} + {type === "loft" ? "🏠" : type === "skill-exchange" ? "🔄" : "☕"}
); } diff --git a/app/history/[id]/page.tsx b/app/history/[id]/page.tsx new file mode 100644 index 0000000..0d85fda --- /dev/null +++ b/app/history/[id]/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useParams } from "next/navigation"; +import Link from "next/link"; +import SessionCoverImage from "../../components/SessionCoverImage"; +import Header from "../../components/Header"; +import Footer from "../../components/Footer"; +import { PAST_SESSIONS } from "@/config/home.config"; +import { ACTIVITY_TYPES } from "@/config/home.config"; + +export default function HistoryDetailPage() { + const params = useParams(); + const id = typeof params.id === "string" ? params.id : ""; + const session = PAST_SESSIONS.find((s) => s.id === id); + + if (!session) { + return ( +
+
+

活动不存在

+ + 返回历史活动 + +
+
+ ); + } + + return ( +
+
+
+ + ← 历史活动 + + +
+
+ {session.coverImage ? ( + + ) : ( +
+ )} +
+ {ACTIVITY_TYPES[session.type]} · {session.city} +

{session.title}

+
+
+
+
+
+
时间
+
{session.date} {session.time}
+
+
+
地点
+
{session.place}
+
+
+
城市
+
{session.city}
+
+
+

+ 这是一场已结束的活动。想参加本周活动?去首页看看~ +

+ + 看看本周活动 + +
+
+
+
+
+ ); +} diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..1ecbd67 --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import SessionCoverImage from "../components/SessionCoverImage"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; +import { PAST_SESSIONS } from "@/config/home.config"; +import { ACTIVITY_TYPES } from "@/config/home.config"; + +export default function HistoryPage() { + return ( +
+
+
+

+ 历史活动 +

+

+ 看看之前都举办了什么~ +

+ +
+
+
+ {PAST_SESSIONS.map((s, i) => ( + +
+ {PAST_SESSIONS.length - i} +
+
+
+
+ {s.coverImage ? ( + + ) : ( +
+ )} +
+
+ + {ACTIVITY_TYPES[s.type]} · {s.city} + +

+ {s.title} +

+

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

+ + 查看详情 → + +
+
+
+ + ))} +
+
+ +
+ + ← 返回首页 + +
+
+
+
+ ); +} diff --git a/app/join/page.tsx b/app/join/page.tsx index c26a7f2..ca43656 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -16,9 +16,7 @@ const statusOptions = [ { value: "other", label: "其他" }, ]; const activityTypeOptions = [ - { value: "cafe", label: "咖啡馆聊天" }, - { value: "bookstore", label: "书店" }, - { value: "citywalk", label: "Citywalk" }, + { value: "loft", label: "loft民宿" }, { value: "side-hustle", label: "轻副业交流" }, { value: "any", label: "都可以" }, ]; @@ -31,8 +29,6 @@ const availableTimeOptions = [ const REGIONS: Record = { 深圳: ["南山区", "福田区", "罗湖区", "宝安区", "龙岗区", "其他"], - 广州: ["天河区", "越秀区", "海珠区", "荔湾区", "白云区", "其他"], - 惠州: ["惠城区", "惠阳区", "其他"], }; interface FormData { @@ -45,6 +41,7 @@ interface FormData { whyJoin: string; nickname: string; wechatOrPhone: string; + xiaohongshuUrl: string; region: string; firstTime: string; availableTime: string; @@ -72,6 +69,7 @@ export default function JoinPage() { whyJoin: "", nickname: "", wechatOrPhone: "", + xiaohongshuUrl: "", region: "", firstTime: "", availableTime: "", @@ -121,6 +119,7 @@ export default function JoinPage() { nickname: form.nickname, age_range: form.ageRange, wechat_or_phone: form.wechatOrPhone, + xiaohongshu_url: form.xiaohongshuUrl.trim(), intro: reasonParts.join(" | "), first_time: form.firstTime, agree_rules: form.agreeRules, @@ -194,20 +193,20 @@ export default function JoinPage() {
- 📍 深圳 · 广州 · 惠州 + 📍 深圳

- 活动报名 + 报名参加

- 先填表,我们会根据场次与匹配度联系合适报名者 + 填个表,我们会根据场次联系你~

- 不是所有报名都会自动确认 + 不是所有报名都会确认,看场次匹配

{/* 进度提示 */} @@ -381,6 +380,22 @@ export default function JoinPage() {

+ +
+ set("xiaohongshuUrl", e.target.value)} + required + className="form-input" + /> +

+ 用于展示头像与昵称,提交前会校验 +

+
+