diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index fb144bc..74a1b81 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -11,6 +11,7 @@ import { } from "@/app/lib/payment"; import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; import type { PayChannel, PayEnv } from "@/app/lib/payment"; +import AuthModal from "@/app/components/AuthModal"; const genderOptions = ["男", "女"]; const singleOptions = ["单身", "恋爱中", "已婚"]; @@ -56,6 +57,8 @@ export default function JoinPage() { const [payRedirecting, setPayRedirecting] = useState(false); const [payEnv, setPayEnv] = useState(null); const [payChannel, setPayChannel] = useState("alipay"); + const [authOpen, setAuthOpen] = useState(false); + const [pendingSubmit, setPendingSubmit] = useState(false); useEffect(() => { if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { @@ -75,8 +78,7 @@ export default function JoinPage() { const set = (key: keyof FormData, value: string) => setForm((prev) => ({ ...prev, [key]: value })); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); + const doSubmit = async () => { setError(null); setSubmitting(true); try { @@ -84,6 +86,7 @@ export default function JoinPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, media: media?.url }), + credentials: "include", }); const data = await res.json(); if (!res.ok || !data?.ok) { @@ -115,6 +118,26 @@ 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; + } + await doSubmit(); + }; + + const handleAuthSuccess = () => { + setAuthOpen(false); + if (pendingSubmit) { + setPendingSubmit(false); + doSubmit(); + } + }; + if (submitted) { return (
@@ -403,40 +426,6 @@ export default function JoinPage() { )}
-
-

💳 支付方式

- {payEnv === "wechat" ? ( -

- 当前在微信内打开,将使用 微信支付 -

- ) : ( -
- - -
- )} -
-
{error && (

@@ -465,6 +454,12 @@ export default function JoinPage() {

+ + { setAuthOpen(false); setPendingSubmit(false); }} + onSuccess={handleAuthSuccess} + /> ); } diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx index 9ee6252..5cc7873 100644 --- a/app/[locale]/join/paid/page.tsx +++ b/app/[locale]/join/paid/page.tsx @@ -1,31 +1,122 @@ "use client"; +import { useState, useEffect } from "react"; import { Link } from "@/i18n/navigation"; /** * 支付完成回跳页 * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页 + * 若 meetup_needs_password_change 为 true,则强制弹窗修改密码 */ export default function JoinPaidPage() { + const [needPasswordChange, setNeedPasswordChange] = useState(null); + const [showChangeModal, setShowChangeModal] = useState(false); + const [newPassword, setNewPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [changeError, setChangeError] = useState(null); + const [changing, setChanging] = useState(false); + + useEffect(() => { + fetch("/api/meetup/need-password-change") + .then((r) => r.json()) + .then((d) => { + setNeedPasswordChange(d?.need === true); + if (d?.need === true) setShowChangeModal(true); + }) + .catch(() => setNeedPasswordChange(false)); + }, []); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setChangeError(null); + if (newPassword.length < 8) { + setChangeError("密码至少 8 位"); + return; + } + if (newPassword !== passwordConfirm) { + setChangeError("两次密码不一致"); + return; + } + setChanging(true); + try { + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword, passwordConfirm }), + credentials: "include", + }); + const data = await res.json(); + if (!res.ok || !data?.ok) { + throw new Error(data?.error || "修改失败"); + } + setShowChangeModal(false); + } catch (err) { + setChangeError(err instanceof Error ? err.message : "修改失败"); + } finally { + setChanging(false); + } + }; + return ( -
-
-
- ✅ + <> +
+
+
+ ✅ +
+

申请已提交

+

+ 我们会尽快审核你的信息 +
+ 审核通过后将通过微信邀请你入群 +

+ + ← 返回首页 +
-

申请已提交

-

- 我们会尽快审核你的信息 -
- 审核通过后将通过微信邀请你入群 -

- - ← 返回首页 -
-
+ + {showChangeModal && ( +
+
+

请修改默认密码

+

+ 为保障账号安全,请立即设置新密码(至少 8 位) +

+
+ { setNewPassword(e.target.value); setChangeError(null); }} + placeholder="新密码" + minLength={8} + required + className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800" + /> + { setPasswordConfirm(e.target.value); setChangeError(null); }} + placeholder="确认新密码" + minLength={8} + required + className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800" + /> + {changeError &&

{changeError}

} + +
+
+
+ )} + ); } diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..2a46930 --- /dev/null +++ b/app/api/auth/change-password/route.ts @@ -0,0 +1,68 @@ +/** + * 修改密码 - 用于 meetup 支付成功后强制修改默认密码 + */ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_NEEDS_PW = "meetup_needs_password_change"; + +function getAuthFromCookie(request: NextRequest): { userId: string; token: string } | null { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) return null; + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + if (payload?.userId && payload?.token) return { userId: payload.userId, token: payload.token }; + return null; + } catch { + return null; + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const newPassword = String(body?.newPassword || "").trim(); + const passwordConfirm = String(body?.passwordConfirm || "").trim(); + + if (newPassword.length < 8) { + return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 }); + } + if (newPassword !== passwordConfirm) { + return NextResponse.json({ ok: false, error: "两次密码不一致" }, { status: 400 }); + } + + const auth = getAuthFromCookie(request); + if (!auth) { + return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 }); + } + + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + password: newPassword, + passwordConfirm: passwordConfirm, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return NextResponse.json( + { ok: false, error: err?.message || "修改失败" }, + { status: 400 } + ); + } + + const r = NextResponse.json({ ok: true }); + r.cookies.set(COOKIE_NEEDS_PW, "", { path: "/", maxAge: 0 }); + return r; + } catch (e) { + console.error("change-password error:", e); + return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..22eeebf --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + return res; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..24b1e87 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, user: null }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const { token, userId, email } = payload; + if (!token || !userId) return NextResponse.json({ ok: true, user: null }); + + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/users/refresh`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const r = NextResponse.json({ ok: true, user: null }); + r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + return r; + } + const data = await res.json(); + const newToken = data.token; + const newRecord = data.record; + if (newToken && newRecord?.id) { + const newPayload = JSON.stringify({ + token: newToken, + userId: newRecord.id, + email: newRecord.email ?? email, + }); + const encoded = Buffer.from(newPayload, "utf-8").toString("base64url"); + const r = NextResponse.json({ + ok: true, + user: { id: newRecord.id, email: newRecord.email ?? email }, + token: newToken, + }); + r.cookies.set(COOKIE_NAME, encoded, { + domain: AUTH_COOKIE_DOMAIN, + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return r; + } + return NextResponse.json({ + ok: true, + user: { id: data.record?.id ?? userId, email: data.record?.email ?? email }, + token: data.token, + }); + } catch { + return NextResponse.json({ ok: true, user: null }); + } +} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts new file mode 100644 index 0000000..d36ebaa --- /dev/null +++ b/app/api/auth/sync-session/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const token = body?.token; + const record = body?.record; + if (!token || !record?.id) { + return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 }); + } + + const payload = JSON.stringify({ + token, + userId: record.id, + email: record.email ?? "", + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, encoded, { + domain: AUTH_COOKIE_DOMAIN, + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return res; +} diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 8b1d398..113adc4 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -1,7 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -function getOrCreateUserId(): string { +const COOKIE_NAME = "pb_session"; + +function getOrCreateUserId(request: NextRequest): string { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (cookie) { + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + if (payload?.userId) return payload.userId; + } catch { + /* ignore */ + } + } return `user${Date.now()}`; } @@ -23,7 +34,7 @@ async function getAdminToken(): Promise { export async function POST(request: NextRequest) { try { - const userId = getOrCreateUserId(); + const userId = getOrCreateUserId(request); const body = await request.json(); const formData = body && typeof body === "object" ? body : {}; diff --git a/app/api/meetup/ensure-user/route.ts b/app/api/meetup/ensure-user/route.ts new file mode 100644 index 0000000..52b849c --- /dev/null +++ b/app/api/meetup/ensure-user/route.ts @@ -0,0 +1,110 @@ +/** + * 加入游牧中国:确保用户存在 + * 新用户:用默认密码 123456789 创建,支付成功后需修改 + * 老用户:验证密码后登录 + */ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; + +const DEFAULT_PASSWORD = "123456789"; +const COOKIE_NEEDS_PW = "meetup_needs_password_change"; + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!res.ok) return null; + const data = await res.json(); + return data?.token ?? null; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const email = String(body?.email || "").trim(); + const password = String(body?.password || "").trim(); + if (!email) { + return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 }); + } + + const pb = getPocketBaseConfig(); + const tryLogin = async (pwd: string) => { + const res = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password: pwd }), + }); + return res; + }; + + let loginRes = await tryLogin(password || DEFAULT_PASSWORD); + if (loginRes.ok) { + const data = await loginRes.json(); + const res = NextResponse.json({ + ok: true, + user_id: data.record?.id, + token: data.token, + record: data.record, + is_new: false, + }); + return res; + } + + const errData = await loginRes.json().catch(() => ({})); + const isNotFound = loginRes.status === 400 && (errData?.message?.includes("Invalid") || errData?.message?.includes("identity")); + if (!isNotFound && password) { + return NextResponse.json({ ok: false, error: "密码错误" }, { status: 400 }); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 }); + } + + const createRes = await fetch(`${pb.url}/api/collections/users/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + email, + password: DEFAULT_PASSWORD, + passwordConfirm: DEFAULT_PASSWORD, + }), + }); + if (!createRes.ok) { + const createErr = await createRes.json().catch(() => ({})); + if (createRes.status === 400 && createErr?.data?.email?.message?.includes("already")) { + return NextResponse.json({ ok: false, error: "该邮箱已注册,请输入密码登录" }, { status: 400 }); + } + return NextResponse.json({ ok: false, error: createErr?.message || "创建账号失败" }, { status: 500 }); + } + + const newUser = await createRes.json(); + const finalLogin = await tryLogin(DEFAULT_PASSWORD); + if (!finalLogin.ok) { + return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 }); + } + const authData = await finalLogin.json(); + + const res = NextResponse.json({ + ok: true, + user_id: authData.record?.id, + token: authData.token, + record: authData.record, + is_new: true, + }); + res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 }); + return res; + } catch (e) { + console.error("ensure-user error:", e); + return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 }); + } +} diff --git a/app/api/meetup/need-password-change/route.ts b/app/api/meetup/need-password-change/route.ts new file mode 100644 index 0000000..ebdb73b --- /dev/null +++ b/app/api/meetup/need-password-change/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COOKIE_NEEDS_PW = "meetup_needs_password_change"; + +export async function GET(request: NextRequest) { + const need = request.cookies.get(COOKIE_NEEDS_PW)?.value === "1"; + return NextResponse.json({ need }); +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 0391543..bbb3cd7 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -14,6 +14,7 @@ async function createPayOrder( const payload = { user_id, + site_id: "meetup", total_fee: Number(total_fee) || config.defaultAmount, type: type || config.defaultType, channel, @@ -23,7 +24,7 @@ async function createPayOrder( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - const res = await fetch(`${config.apiUrl}/payh5`, { + const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -56,7 +57,7 @@ export async function POST(request: NextRequest) { const user_id = String(body?.user_id || "").trim(); const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`; - const total_fee = Number(body?.total_fee) || 80; + const total_fee = Number(body?.total_fee) || 100; const type = String(body?.type || "meetup"); const channel = String(body?.channel || "alipay"); const device = String(body?.device || "pc"); diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index 82b175b..67abc72 100644 --- a/app/api/pay/status/route.ts +++ b/app/api/pay/status/route.ts @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 25000); const res = await fetch( - `${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, + `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, { cache: "no-store", signal: controller.signal } ).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts new file mode 100644 index 0000000..9bdc5f9 --- /dev/null +++ b/app/api/vip/check/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; + +const COOKIE_NAME = "pb_session"; + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!res.ok) return null; + const data = await res.json(); + return data?.token ?? null; +} + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, vip: false }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const userId = payload?.userId; + if (!userId) return NextResponse.json({ ok: true, vip: false }); + + const token = await getAdminToken(); + if (!token) return NextResponse.json({ ok: true, vip: false }); + + const pb = getPocketBaseConfig(); + const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`; + const res = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return NextResponse.json({ ok: true, vip: false }); + const data = await res.json(); + const items = data?.items ?? []; + const record = items[0]; + const now = new Date().toISOString(); + const isExpired = record?.expires_at && record.expires_at < now; + const vip = !!record && !isExpired; + return NextResponse.json({ + ok: true, + vip, + expires_at: record?.expires_at ?? null, + }); + } catch { + return NextResponse.json({ ok: true, vip: false }); + } +} diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx index edb38fc..6bfa1fc 100644 --- a/app/components/AuthModal.tsx +++ b/app/components/AuthModal.tsx @@ -47,6 +47,15 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps result = await pbLogin(email, password); } pbSaveAuth(result.token, result.record); + try { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: result.token, record: result.record }), + }); + } catch { + /* ignore */ + } onSuccess(email); onClose(); setEmail(""); diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index c1e4475..5682fca 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -3,6 +3,14 @@ import { useState } from "react"; import { Link, useLocale } from "@/i18n/navigation"; import { useTranslation } from "@/i18n/navigation"; +import { + getPayEnv, + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import type { PayChannel, PayEnv } from "@/app/lib/payment"; const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"]; const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"]; @@ -34,11 +42,49 @@ export default function HeroSection() { const { t: tHero } = useTranslation("hero"); const locale = useLocale(); const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const handleJoin = (e: React.FormEvent) => { + const handleJoin = async (e: React.FormEvent) => { e.preventDefault(); - if (email.trim()) { - alert(locale === "zh" ? `欢迎加入游牧中国!我们将发送确认邮件到 ${email}` : `Welcome! We'll send a confirmation email to ${email}`); + const em = email.trim(); + if (!em) { + setError(locale === "zh" ? "请输入邮箱" : "Please enter your email"); + return; + } + setError(null); + setLoading(true); + try { + const res = await fetch("/api/meetup/ensure-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: em, password: password || undefined }), + credentials: "include", + }); + const data = await res.json(); + if (!res.ok || !data?.ok) { + throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed")); + } + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: data.token, record: data.record }), + }); + const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid"; + const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc"; + const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + redirectToPay({ + user_id: data.user_id, + return_url: returnUrl, + channel, + device: getDeviceFromEnv(env), + useJoinConfig: true, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + } finally { + setLoading(false); } }; @@ -151,18 +197,29 @@ export default function HeroSection() { setEmail(e.target.value)} + onChange={(e) => { setEmail(e.target.value); setError(null); }} placeholder={tHero("emailPlaceholder")} 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" /> + { setPassword(e.target.value); setError(null); }} + placeholder={locale === "zh" ? "密码(老用户必填)" : "Password (required for existing users)"} + 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}

+ )}

- {tHero("loginHint")} + {locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"}

diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 5c8910b..d64975e 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -22,11 +22,28 @@ export default function NavbarComponent() { const navLinks = getNavLinks(); useEffect(() => { - setUserEmail(getStoredUserEmail()); + let mounted = true; + (async () => { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!mounted) return; + if (data?.user && data?.token) { + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); + setUserEmail(data.user.email); + return; + } + } catch { + /* ignore */ + } + if (mounted) setUserEmail(getStoredUserEmail()); + })(); + return () => { mounted = false; }; }, []); - const handleLogout = useCallback(() => { - pbLogout(); + const handleLogout = useCallback(async () => { + await pbLogout(); setUserEmail(null); }, []); @@ -104,14 +121,7 @@ export default function NavbarComponent() { {t("logout")}
- ) : ( - - )} + ) : null} - ) : ( - - )} + ) : null} )} diff --git a/app/components/SiteMenu.tsx b/app/components/SiteMenu.tsx index 1e29fd0..3ba3339 100644 --- a/app/components/SiteMenu.tsx +++ b/app/components/SiteMenu.tsx @@ -85,7 +85,7 @@ export default memo(function SiteMenu({ { titleKey: "other", items: [ - { labelKey: "digitalNomadGuide", href: "https://digital.hackrobot.cn/", icon: "📖" }, + { labelKey: "digitalNomadGuide", href: process.env.NEXT_PUBLIC_DIGITAL_URL || "https://digital.hackrobot.cn/", icon: "📖" }, { labelKey: "remoteJobs", href: "#", icon: "💼" }, { labelKey: "coworking", href: "#", icon: "🏢" }, ], diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index 9a90946..c1d3c66 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -67,10 +67,16 @@ export function pbSaveAuth(token: string, record: AuthUser): void { localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record)); } -export function pbLogout(): void { +/** 登出 - 清除本地存储和根域 Cookie */ +export async function pbLogout(): Promise { if (typeof window === "undefined") return; localStorage.removeItem(PB_STORAGE_KEYS.token); localStorage.removeItem(PB_STORAGE_KEYS.user); + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch { + /* ignore */ + } } export function getStoredUserEmail(): string | null { diff --git a/config/domain.config.ts b/config/domain.config.ts new file mode 100644 index 0000000..2cf30ae --- /dev/null +++ b/config/domain.config.ts @@ -0,0 +1,23 @@ +/** + * 域名与根域配置 - 特色标识 + * 更换域名(如 *.hackrobot.cn -> *.nomadro.com)时,只需设置 ROOT_DOMAIN 或各独立变量 + */ + +const isDev = process.env.NODE_ENV === "development"; + +export const ROOT_DOMAIN = + process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn"; + +export const AUTH_COOKIE_DOMAIN = + process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; + +export const DEFAULT_PAYMENT_API_URL = isDev + ? "http://127.0.0.1:8700" + : `https://api.${ROOT_DOMAIN}`; + +export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`; + +export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`; + +/** digital 专题站默认地址(用于跨站链接) */ +export const DEFAULT_DIGITAL_URL = `https://digital.${ROOT_DOMAIN}`; diff --git a/config/index.ts b/config/index.ts index 9f686f2..a8ff674 100644 --- a/config/index.ts +++ b/config/index.ts @@ -8,6 +8,7 @@ export { getPaymentConfig, getJoinPaymentConfig, getMinioConfig, + SITE_ID, } from "./services.config"; export type { PocketBaseConfig, diff --git a/config/services.config.ts b/config/services.config.ts index 1ba1908..7812e51 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -1,7 +1,14 @@ /** * 服务配置 - PocketBase、支付、MinIO + * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认 API 地址 */ +import { + DEFAULT_PAYMENT_API_URL, + DEFAULT_POCKETBASE_URL, + DEFAULT_MINIO_HOST, +} from "./domain.config"; + export interface PocketBaseConfig { enabled: boolean; url: string; @@ -33,13 +40,19 @@ export interface MinioConfig { const isDev = process.env.NODE_ENV === "development"; +/** 站点标识,用于 VIP 按站点隔离 */ +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup"; + +/** meetup 加入游牧中国 价格(分),测试用1元 */ +export const MEETUP_JOIN_AMOUNT = 100; + export function getPocketBaseConfig(): PocketBaseConfig { return { enabled: true, url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || - "https://pocketbase.hackrobot.cn", + DEFAULT_POCKETBASE_URL, usersCollection: "users", joinCollection: process.env.POCKETBASE_JOIN_COLLECTION || "solan", }; @@ -48,11 +61,9 @@ export function getPocketBaseConfig(): PocketBaseConfig { export function getPaymentConfig(): PaymentConfig { return { enabled: true, - apiUrl: - process.env.PAYMENT_API_URL || - (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"), + apiUrl: process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL, provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", - defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80, + defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 100, defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup", zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", }; @@ -60,10 +71,9 @@ export function getPaymentConfig(): PaymentConfig { /** 获取「加入」场景的支付配置 */ export function getJoinPaymentConfig(): { amount: number; type: string } { - const payment = getPaymentConfig(); return { - amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || payment.defaultAmount, - type: process.env.PAYMENT_JOIN_TYPE || payment.defaultType, + amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || MEETUP_JOIN_AMOUNT, + type: process.env.PAYMENT_JOIN_TYPE || "meetup", }; } @@ -71,7 +81,7 @@ export function getMinioConfig(themeOverride?: { bucket?: string; uploadPrefix?: string; }): MinioConfig { - const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn"; + const endPoint = process.env.MINIO_ENDPOINT || DEFAULT_MINIO_HOST; const port = parseInt(process.env.MINIO_PORT || "9035", 10); const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot"; const publicUrl =