diff --git a/app/api/join/route.ts b/app/api/join/route.ts index ff1e894..b4c5865 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { postSalonApi } from "@/app/lib/salonApi"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { getPocketBaseConfig } from "@/config/services.config"; import { COOKIE_NAME } from "@/app/lib/auth-cookie"; function getUserIdFromCookie(request: NextRequest): string | null { @@ -13,42 +15,73 @@ function getUserIdFromCookie(request: NextRequest): string | null { } } +function generateAnonId(): string { + return "anon_" + crypto.randomUUID().replace(/-/g, ""); +} + export async function POST(request: NextRequest) { try { - const userId = getUserIdFromCookie(request); - if (!userId) { - return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 }); - } const body = await request.json(); const formData = body && typeof body === "object" ? body : {}; - const record = { - user_id: userId, - nickname: formData.nickname || "", - occupation: formData.profession || "", - reason: formData.intro || "", - single: formData.single || "", - wechatId: formData.wechat || "", - gender: formData.gender || "", - education: formData.education || "", - gradYear: formData.graduationYear || "", - phone: formData.phone || "", - media: formData.media || "", - }; + const nickname = String(formData.nickname || "").trim(); + const city = String(formData.city || "").trim(); + const session = String(formData.session || "").trim(); + const ageRange = String(formData.age_range || "").trim(); + const wechatOrPhone = String(formData.wechat_or_phone || "").trim(); + const intro = String(formData.intro || "").trim(); + const firstTime = String(formData.first_time || "").trim(); + const agreeRules = !!formData.agree_rules; - const { status, data } = await postSalonApi(request, "/api/salon/join", record); - if (status >= 500) { - const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + if (!nickname || !wechatOrPhone || !intro || !session || !agreeRules) { return NextResponse.json( - { ok: false, error: (payload.detail as string) || (payload.error as string) || "提交失败" }, - { status: 503 } + { ok: false, error: "请填写必填项" }, + { status: 400 } ); } - if (status >= 400) { - return NextResponse.json(data, { status }); + + const userId = getUserIdFromCookie(request) || generateAnonId(); + + const record = { + user_id: userId, + nickname, + occupation: city, + reason: intro, + wechatId: wechatOrPhone, + gender: "", + education: "", + gradYear: "", + phone: wechatOrPhone, + single: "", + media: "", + type: session, + order_id: "", + amount: 0, + age_range: ageRange, + first_time: firstTime, + }; + + try { + const { status, data } = await postSalonApi(request, "/api/salon/join", record); + if (status >= 500) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json( + { ok: false, error: (payload.detail as string) || (payload.error as string) || "提交失败" }, + { status: 503 } + ); + } + if (status >= 400) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + const errMsg = (payload.error as string) || (payload.detail as string) || "提交失败"; + if (status === 401) { + return tryPocketBaseDirect(record, errMsg); + } + return NextResponse.json({ ok: false, error: errMsg }, { status }); + } + return NextResponse.json({ ok: true }); + } catch { + return tryPocketBaseDirect(record, "服务暂时不可用"); } - const payload = typeof data === "object" && data !== null ? (data as Record) : {}; - return NextResponse.json({ ok: true, user_id: payload.user_id ?? userId }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("Join API error:", e); @@ -58,3 +91,58 @@ export async function POST(request: NextRequest) { ); } } + +async function tryPocketBaseDirect( + record: Record, + fallbackError: string +): Promise { + const token = await getAdminToken(); + if (!token) { + return NextResponse.json({ ok: false, error: fallbackError }, { status: 503 }); + } + + const pb = getPocketBaseConfig(); + const base = pb.url.replace(/\/$/, ""); + const url = `${base}/api/collections/${pb.joinCollection}/records`; + + 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(" | "), + 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, + }; + + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + const msg = (errData as { message?: string }).message || fallbackError; + return NextResponse.json({ ok: false, error: msg }, { status: res.status }); + } + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("PocketBase direct write error:", e); + return NextResponse.json( + { ok: false, error: fallbackError }, + { status: 503 } + ); + } +} diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 244ea39..572f753 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -2,7 +2,7 @@ export default function Footer() { return (
-

© {new Date().getFullYear()} Salon. 探索中国,远程工作,自由生活。

+

© {new Date().getFullYear()} Salon. 北京周末新朋友活动。

); diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index aaa032e..013bda1 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -1,158 +1,8 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from "react"; import Link from "next/link"; -import JoinModal from "./JoinModal"; -import { - getPayEnv, - redirectToPay, - getDeviceFromEnv, -} from "@/app/lib/payment"; -import type { PayChannel } from "@/app/lib/payment"; - -const avatarPhotos = [ - "https://i.pravatar.cc/150?img=32", - "https://i.pravatar.cc/150?img=33", - "https://i.pravatar.cc/150?img=25", - "https://i.pravatar.cc/150?img=52", - "https://i.pravatar.cc/150?img=44", -]; - -function isPrivateDevHost(hostname: string): boolean { - if (!hostname) return false; - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; - if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) return true; - const match = hostname.match(/^172\.(\d{1,2})\./); - if (!match) return false; - const secondOctet = Number(match[1]); - return secondOctet >= 16 && secondOctet <= 31; -} - -function buildPendingUserId(email: string): string { - return ( - "pending_" + - Array.from(new TextEncoder().encode(email)) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - ); -} export default function HeroSection() { - const [email, setEmail] = useState(""); - const [joinModalOpen, setJoinModalOpen] = useState(false); - const [joinModalVipOnly, setJoinModalVipOnly] = useState(false); - const [checking, setChecking] = useState(false); - const [error, setError] = useState(null); - const [isLoggedIn, setIsLoggedIn] = useState(false); - - useEffect(() => { - const checkAuth = async () => { - try { - const res = await fetch("/api/auth/me", { credentials: "include" }); - const data = await res.json(); - setIsLoggedIn(!!data?.user); - } catch { - setIsLoggedIn(false); - } - }; - checkAuth(); - const onAuth = () => checkAuth(); - window.addEventListener("auth:updated", onAuth); - return () => window.removeEventListener("auth:updated", onAuth); - }, []); - - const checkUserCacheRef = useRef<{ email: string; data: { exists: boolean; vip: boolean }; ts: number } | null>(null); - const CACHE_TTL_MS = 60_000; - - const handleCheckAndProceed = useCallback( - async (em: string) => { - if (checking) return; - const trimmed = em.trim(); - if (!trimmed) { - setError("请输入邮箱"); - return; - } - setError(null); - setChecking(true); - try { - const cached = checkUserCacheRef.current; - if (cached && cached.email === trimmed && Date.now() - cached.ts < CACHE_TTL_MS) { - const { exists, vip } = cached.data; - if (exists) { - setJoinModalVipOnly(!!vip); - setJoinModalOpen(true); - return; - } - } - const checkRes = await fetch("/api/salon/check-user", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: trimmed }), - credentials: "include", - }); - const checkData = await checkRes.json(); - if (!checkRes.ok || !checkData?.ok) { - throw new Error(checkData?.error || checkData?.detail || "操作失败"); - } - if (checkData.exists) { - checkUserCacheRef.current = { email: trimmed, data: { exists: true, vip: !!checkData.vip }, ts: Date.now() }; - setJoinModalVipOnly(!!checkData.vip); - setJoinModalOpen(true); - return; - } - const shouldPrecreateUser = - typeof window !== "undefined" && - isPrivateDevHost(window.location.hostname); - let user_id = buildPendingUserId(trimmed); - if (shouldPrecreateUser) { - const ensureRes = await fetch("/api/salon/ensure-user", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: trimmed }), - credentials: "include", - }); - const ensureData = await ensureRes.json(); - if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) { - throw new Error(ensureData?.error || ensureData?.detail || "创建用户失败"); - } - if (ensureData.is_new) { - await fetch("/api/salon/set-needs-password-change", { method: "POST", credentials: "include" }); - } - user_id = String(ensureData.user_id).trim(); - if (ensureData?.token && ensureData?.record) { - await fetch("/api/auth/sync-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: ensureData.token, record: ensureData.record }), - credentials: "include", - }); - } - } - const returnUrl = - typeof window !== "undefined" ? `${window.location.origin}/join/paid` : "/join/paid"; - const env = getPayEnv(); - const channel: PayChannel = "wxpay"; - redirectToPay({ - user_id, - return_url: returnUrl, - channel, - device: getDeviceFromEnv(env), - useJoinConfig: true, - }); - } catch (err) { - setError(err instanceof Error ? err.message : "操作失败"); - } finally { - setChecking(false); - } - }, - [checking] - ); - - const handleJoinClick = (e: React.FormEvent) => { - e.preventDefault(); - handleCheckAndProceed(email); - }; - return (
@@ -168,26 +18,26 @@ export default function HeroSection() {
- 🏆 数字游民首选 + 📍 北京周末

- 探索中国 + 认识新朋友
- 远程工作,自由生活 + 不尬聊,不乱收费

- 加入全球数字游民社区,发现中国最适合远程工作和生活的城市 + 公开场地,小组活动,规则透明。先看场次,再决定要不要报名

{[ - { icon: "🍹", text: "线下聚会与线上分享" }, - { icon: "❤️", text: "结识志同道合的伙伴" }, - { icon: "🧪", text: "探索新城市与新可能" }, - { icon: "🌎", text: "全球数字游民网络" }, - { icon: "💬", text: "社区互助与资源共享" }, + { icon: "📍", text: "公开场地,地址提前告知" }, + { icon: "👥", text: "小组活动,人数可控" }, + { icon: "📋", text: "规则透明,先了解再报名" }, + { icon: "✍️", text: "填个简单表单即可,无需注册登录" }, + { icon: "🌸", text: "女生友好的表达方式" }, ].map((f) => (
{f.icon} @@ -198,72 +48,34 @@ export default function HeroSection() { ))}
-
-
- {avatarPhotos.slice(0, 8).map((src, i) => ( - - ))} -
- - 已有 5,000+ 成员加入 - -
+ + 查看本周场次,去报名 → +
- +
+

轻报名

+

不先登录,不先付款

+
- {!isLoggedIn ? ( -
- { setEmail(e.target.value); setError(null); }} - placeholder="输入邮箱,加入社区" - className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3" - /> - {error && ( -

{error}

- )} - -
- ) : ( -
-

- 您已登录,可前往社区参与互动 -

-
- )} -
- { setJoinModalOpen(false); setJoinModalVipOnly(false); }} - initialEmail={email.trim()} - vipOnly={joinModalVipOnly} - /> - -
- - 💬 加入社区 - +
+

+ 选好场次,填个简单表单即可参与 +

+ + 去报名 → + +
diff --git a/app/join/page.tsx b/app/join/page.tsx index 8852301..ea37619 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -1,138 +1,79 @@ "use client"; -import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import Link from "next/link"; -import { - getPayEnv, - redirectToPay, - getDeviceFromEnv, -} from "@/app/lib/payment"; -import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; -import AuthModal from "@/app/components/AuthModal"; +import { SESSIONS } from "@/config/home.config"; -const genderOptions = ["男", "女"]; -const singleOptions = ["单身", "恋爱中", "已婚"]; -const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"]; -const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i)); +const ageOptions = ["18-24", "25-30", "31-35", "36-40", "40+"]; +const firstTimeOptions = ["是,第一次参加", "否,参加过类似活动"]; interface FormData { + city: string; + session: string; nickname: string; - gender: string; - single: string; - profession: string; + ageRange: string; + wechatOrPhone: string; intro: string; - wechat: string; - education: string; - graduationYear: string; - phone: string; + firstTime: string; + agreeRules: boolean; } -interface MediaInfo { - preview: string; - url: string; - type: "image" | "video"; +function getSessionFromUrl(): string { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("session") || ""; +} + +function getSessionInfo(sessionId: string) { + return SESSIONS.find((s) => s.id === sessionId); } export default function JoinPage() { const [form, setForm] = useState({ + city: "北京", + session: "", nickname: "", - gender: "", - single: "", - profession: "", + ageRange: "", + wechatOrPhone: "", intro: "", - wechat: "", - education: "", - graduationYear: "", - phone: "", + firstTime: "", + agreeRules: false, }); - const [media, setMedia] = useState(null); - const [uploading, setUploading] = useState(false); - const fileRef = useRef(null); const [submitted, setSubmitted] = useState(false); - const [isVipSubmit, setIsVipSubmit] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [payRedirecting, setPayRedirecting] = useState(false); - const [authOpen, setAuthOpen] = useState(false); - const [pendingSubmit, setPendingSubmit] = useState(false); - const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false); - const [checkingStatus, setCheckingStatus] = useState(true); useEffect(() => { - if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { - setSubmitted(true); - setCheckingStatus(false); - return; - } - const checkExisting = async () => { - const meRes = await fetch("/api/auth/me", { credentials: "include" }); - const meData = await meRes.json(); - if (!meData?.user) { - setCheckingStatus(false); - return; - } - const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" }); - const statusData = await statusRes.json(); - if (statusData?.hasRecord && statusData?.isComplete) { - setIsAlreadyRecorded(true); - setSubmitted(true); - } - setCheckingStatus(false); - }; - checkExisting(); + const sid = getSessionFromUrl(); + if (sid) setForm((prev) => ({ ...prev, session: sid })); }, []); - usePayStatusPoll((orderId) => { - if (typeof window === "undefined") return; - const paidPath = "/join/paid"; - const url = orderId ? `${paidPath}?order_id=${encodeURIComponent(orderId)}` : paidPath; - window.location.replace(url); - }); - - const set = (key: keyof FormData, value: string) => + const set = (key: keyof FormData, value: string | boolean) => setForm((prev) => ({ ...prev, [key]: value })); - const doSubmit = async () => { + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); setError(null); setSubmitting(true); try { const res = await fetch("/api/join", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...form, media: media?.url }), - credentials: "include", + body: JSON.stringify({ + city: form.city, + session: form.session, + nickname: form.nickname, + age_range: form.ageRange, + wechat_or_phone: form.wechatOrPhone, + intro: form.intro, + first_time: form.firstTime, + agree_rules: form.agreeRules, + }), }); const data = await res.json(); if (!res.ok || !data?.ok) { throw new Error(data?.error || "提交失败,请稍后重试"); } - const userId = data.user_id; - if (!userId) { - setSubmitted(true); - return; - } - const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); - const vipData2 = await vipRes2.json(); - if (vipData2?.vip) { - setIsVipSubmit(true); - setSubmitted(true); - setSubmitting(false); - return; - } - setSubmitting(false); - setPayRedirecting(true); - const returnUrl = `${window.location.origin}/join/paid`; - const env = getPayEnv(); - const channel = "wxpay"; - const device = getDeviceFromEnv(env); - redirectToPay({ - user_id: userId, - return_url: returnUrl, - channel, - device, - useJoinConfig: true, - }); - return; + setSubmitted(true); } catch (err) { setError(err instanceof Error ? err.message : "网络错误,请重试"); } finally { @@ -140,30 +81,7 @@ export default function JoinPage() { } }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - const meRes = await fetch("/api/auth/me", { credentials: "include" }); - const meData = await meRes.json(); - if (!meData?.user) { - setPendingSubmit(true); - setAuthOpen(true); - return; - } - const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); - const vipData = await vipRes.json(); - if (vipData?.vip) { - setIsVipSubmit(true); - } - await doSubmit(); - }; - - const handleAuthSuccess = () => { - setAuthOpen(false); - if (pendingSubmit) { - setPendingSubmit(false); - doSubmit(); - } - }; + const selectedSession = getSessionInfo(form.session); if (submitted) { return ( @@ -172,11 +90,9 @@ export default function JoinPage() {
-

- {isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"} -

+

报名成功

- {isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"} + 我们会尽快通过您留下的联系方式与您确认

-
加载中…
-
- ); - } - return (
-
🌍
+
📍
- 数字游民社区 + 北京周末新朋友活动

- 🌍 数字游民社区报名 + 活动报名

- {"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"} + 填个简单表单即可,无需注册登录

+ {selectedSession && ( +
+

已选场次

+

{selectedSession.title}

+

{selectedSession.date} {selectedSession.time} · {selectedSession.place}

+
+ )} +
- + set("city", e.target.value)} + required + className="form-input" + /> + + + + + +
+ +
+ + set("nickname", e.target.value)} required @@ -237,222 +182,81 @@ export default function JoinPage() { /> - +
-
- - - - - - set("profession", e.target.value)} - required - className="form-input" - /> - -
- -
- - set("wechat", e.target.value)} - required - className="form-input" - /> - - - - { - const v = e.target.value.replace(/\D/g, "").slice(0, 11); - set("phone", v); - }} - required - className="form-input" - /> - -
-
-
-