diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 22eeebf..7e07949 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,10 +1,15 @@ -import { NextResponse } from "next/server"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { NextRequest, NextResponse } from "next/server"; +import { getAuthCookieDomain } from "@/config/domain.config"; const COOKIE_NAME = "pb_session"; -export async function POST() { +export async function POST(request: NextRequest) { + const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + res.cookies.set(COOKIE_NAME, "", { + ...(domain && { domain }), + path: "/", + maxAge: 0, + }); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 24b1e87..53952fe 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,12 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { getAuthCookieDomain } 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; + const domain = getAuthCookieDomain(request); if (!cookie) { return NextResponse.json({ ok: true, user: null }); } @@ -21,7 +22,7 @@ export async function GET(request: NextRequest) { }); if (!res.ok) { const r = NextResponse.json({ ok: true, user: null }); - r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 }); return r; } const data = await res.json(); @@ -40,7 +41,7 @@ export async function GET(request: NextRequest) { token: newToken, }); r.cookies.set(COOKIE_NAME, encoded, { - domain: AUTH_COOKIE_DOMAIN, + ...(domain && { domain }), path: "/", maxAge: COOKIE_MAX_AGE, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index d36ebaa..6fa0780 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { getAuthCookieDomain } from "@/config/domain.config"; const COOKIE_NAME = "pb_session"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 @@ -19,9 +19,10 @@ export async function POST(request: NextRequest) { }); const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); res.cookies.set(COOKIE_NAME, encoded, { - domain: AUTH_COOKIE_DOMAIN, + ...(domain && { domain }), path: "/", maxAge: COOKIE_MAX_AGE, secure: process.env.NODE_ENV === "production", diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 5682fca..c4f4da4 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -3,14 +3,7 @@ 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"; +import JoinModal from "./JoinModal"; const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"]; const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"]; @@ -38,54 +31,14 @@ const features = [ ]; export default function HeroSection() { - const { t } = useTranslation("common"); 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 [joinModalOpen, setJoinModalOpen] = useState(false); - const handleJoin = async (e: React.FormEvent) => { + const handleJoinClick = (e: React.FormEvent) => { e.preventDefault(); - 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); - } + setJoinModalOpen(true); }; return ( @@ -193,36 +146,30 @@ export default function HeroSection() { -
+ { setEmail(e.target.value); setError(null); }} + onChange={(e) => setEmail(e.target.value)} 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}

- )}

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

+ setJoinModalOpen(false)} + initialEmail={email.trim()} + />
diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx new file mode 100644 index 0000000..2d7055c --- /dev/null +++ b/app/components/JoinModal.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect, type FormEvent } from "react"; +import { createPortal } from "react-dom"; +import { useLocale } from "@/i18n/navigation"; +import { + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import type { PayChannel, PayEnv } from "@/app/lib/payment"; + +interface JoinModalProps { + isOpen: boolean; + onClose: () => void; + initialEmail?: string; +} + +export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinModalProps) { + const locale = useLocale(); + const [email, setEmail] = useState(initialEmail); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setEmail(initialEmail); + setPassword(""); + setError(null); + } + }, [isOpen, initialEmail]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + 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 }), + credentials: "include", + }); + 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); + } + }; + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!isOpen || !mounted) return null; + + const modalContent = ( +
+
+
+
+ + +

+ {locale === "zh" ? "加入游牧中国" : "Join Nomad China"} +

+

+ {locale === "zh" ? "新用户将自动注册,老用户请输入密码" : "New users auto-register; existing users enter password"} +

+ +
+
+ + { setEmail(e.target.value); setError(null); }} + placeholder={locale === "zh" ? "输入你的邮箱..." : "Enter your email..."} + required + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+
+ + { setPassword(e.target.value); setError(null); }} + placeholder={locale === "zh" ? "新用户可留空" : "Leave empty for new users"} + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+ {error && ( +

{error}

+ )} + +
+ +

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

+
+
+
+ ); + + return createPortal(modalContent, document.body); +} diff --git a/config/domain.config.ts b/config/domain.config.ts index 2cf30ae..40654c2 100644 --- a/config/domain.config.ts +++ b/config/domain.config.ts @@ -11,6 +11,15 @@ export const ROOT_DOMAIN = export const AUTH_COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; +/** 根据请求 host 返回 Cookie domain。localhost 时不设 domain,生产环境用根域实现跨站 SSO */ +export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined { + const envDomain = process.env.AUTH_COOKIE_DOMAIN; + if (envDomain) return envDomain; + const host = request.headers.get("host") ?? ""; + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined; + return AUTH_COOKIE_DOMAIN; +} + export const DEFAULT_PAYMENT_API_URL = isDev ? "http://127.0.0.1:8700" : `https://api.${ROOT_DOMAIN}`;