From 6d479c9f948b8ad3c84a0e5c377a638bdf4f5bc2 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 7 Jun 2026 22:50:42 -0500 Subject: [PATCH] 's' --- .env.example | 46 ++++++---- app/[locale]/city/[slug]/page.tsx | 137 ++++++++++++++++++++++++++++- app/[locale]/dashboard/page.tsx | 45 ++++++---- app/[locale]/digital/join/page.tsx | 8 -- app/[locale]/join/page.tsx | 51 ++++------- app/[locale]/meetups/page.tsx | 51 +++++++++-- app/[locale]/page.tsx | 21 ++--- app/components/AuthModal.tsx | 13 +-- app/components/CityCard.tsx | 10 +-- app/components/HeroSection.tsx | 52 +++++------ app/components/JoinModal.tsx | 28 +++--- app/components/Navbar.tsx | 54 ++++-------- app/components/ThemeToggle.tsx | 1 + app/context/ThemeContext.tsx | 34 ++++--- app/digital/lib/payEnv.ts | 3 +- app/globals.css | 46 +++------- app/layout.tsx | 29 ++---- app/lib/api-client.ts | 68 ++++++++++++++ app/lib/charts/MembersMap.tsx | 2 +- app/lib/payEnv.ts | 3 +- eslint.config.mjs | 4 + messages/en.json | 3 +- messages/zh.json | 3 +- 23 files changed, 435 insertions(+), 277 deletions(-) diff --git a/.env.example b/.env.example index 68334a4..0c8d122 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,50 @@ -# ========== 与 payjsapi/digital/nomadvip 同源 ========== +# ========== NomadCNA 环境变量示例(勿提交真实密钥) ========== APP_ENV=development NEXT_PUBLIC_APP_ENV=development -NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn -POCKETBASE_JOIN_COLLECTION=solan -POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com -POCKETBASE_PASSWORD=Xiao4669805@ -# ZPay 支付 (payjsapi) -PAYMENT_API_URL=http://127.0.0.1:8007 +# PocketBase(服务端) +POCKETBASE_URL=http://127.0.0.1:8090 +POCKETBASE_EMAIL=admin@example.com +POCKETBASE_PASSWORD=changeme +NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090 +POCKETBASE_JOIN_COLLECTION=member_applications + +# 支付 +PAYMENT_API_URL=http://127.0.0.1:8000 PAYMENT_PROVIDER=zpay PAYMENT_DEFAULT_AMOUNT=80 PAYMENT_DEFAULT_TYPE=meetup PAYMENT_JOIN_AMOUNT=80 PAYMENT_JOIN_TYPE=meetup +ZPAY_PID=your_zpay_pid +ZPAY_KEY=your_zpay_key ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php +XORPAY_AID=your_xorpay_aid +XORPAY_SECRET=your_xorpay_secret # MinIO 存储 -MINIO_ENDPOINT=minioweb.hackrobot.cn -MINIO_PORT=9035 +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=9000 MINIO_USE_SSL=false -MINIO_BUCKET=hackrobot -MINIO_ACCESS_KEY= -MINIO_SECRET_KEY= +MINIO_BUCKET=nomadcna +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin MINIO_REGION=china -MINIO_UPLOAD_PREFIX=joins +MINIO_UPLOAD_PREFIX=uploads +NEXT_PUBLIC_MINIO_PUBLIC_URL=http://127.0.0.1:9000/nomadcna -# 站点(本地调试可改为 http://192.168.41.222:3001) +# 站点 NEXT_PUBLIC_SITE_URL=http://localhost:3001 +NEXT_PUBLIC_SITE_ID=nomadcna +NEXT_PUBLIC_API_BASE_URL= -# MiroTalk P2P 视频会议 +# 视频会议与群聊 NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn +# FastAPI 代理(本地开发默认 127.0.0.1:8000) +FASTAPI_PROXY_TARGET=http://127.0.0.1:8000 +FRONTEND_ORIGINS=http://localhost:3001,http://127.0.0.1:3001 + # 生产环境跨站 SSO(本地不设置) -# AUTH_COOKIE_DOMAIN=.hackrobot.cn +# AUTH_COOKIE_DOMAIN=.nomadro.cn diff --git a/app/[locale]/city/[slug]/page.tsx b/app/[locale]/city/[slug]/page.tsx index e410b86..f337fd6 100644 --- a/app/[locale]/city/[slug]/page.tsx +++ b/app/[locale]/city/[slug]/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { use } from "react"; -import { useTranslation, Link } from "@/i18n/navigation"; +import { use, useEffect, useMemo, useState } from "react"; +import { Link } from "@/i18n/navigation"; import Footer from "@/app/components/Footer"; +import { apiFetch } from "@/app/lib/api-client"; interface CityGuide { slug: string; @@ -149,11 +150,134 @@ const CITY_GUIDES: CityGuide[] = [ }, ]; +type CityApiRecord = { + name?: string; + nameEn?: string; + icon?: string; + description?: string; + costPerMonth?: number; + internetSpeed?: number; + nomadsNow?: number; + tags?: string[]; +}; + +type CityDetailRecord = { + guide?: { + summary?: string; + arrivalChecklist?: string[]; + workSetup?: string; + bestFor?: string[]; + }; + cost?: { + breakdown?: Array<{ label?: string; amount?: number }>; + monthlyTotal?: number; + tip?: string; + }; + pros?: { + pros?: string[]; + cons?: string[]; + }; +}; + +function mergeCityGuide(slug: string, apiCity?: CityApiRecord | null, detail?: CityDetailRecord | null): CityGuide | null { + const fallback = CITY_GUIDES.find((c) => c.slug === slug); + if (!apiCity && !fallback) return null; + + const breakdown = detail?.cost?.breakdown || []; + const costOfLiving = + breakdown.length > 0 + ? breakdown.map((item) => ({ + item: String(item.label || "项目"), + priceZh: item.amount ? `¥${Number(item.amount).toLocaleString("zh-CN")}/月` : "—", + priceEn: item.amount ? `¥${Number(item.amount).toLocaleString("en-US")}/month` : "—", + })) + : fallback?.costOfLiving || []; + + const tips = + (detail?.guide?.arrivalChecklist?.length ? detail.guide.arrivalChecklist : null) || + fallback?.tips || + []; + + return { + slug, + nameZh: apiCity?.name || fallback?.nameZh || slug, + nameEn: apiCity?.nameEn || fallback?.nameEn || slug, + emoji: apiCity?.icon || fallback?.emoji || "🏙️", + descriptionZh: + detail?.guide?.summary || + apiCity?.description || + fallback?.descriptionZh || + "", + descriptionEn: fallback?.descriptionEn || "", + costOfLiving, + coworking: fallback?.coworking || [], + neighborhoods: + detail?.pros?.pros?.length || detail?.pros?.cons?.length + ? [ + ...(detail?.pros?.pros || []).slice(0, 2).map((pros, index) => ({ + name: `优势 ${index + 1}`, + pros, + cons: detail?.pros?.cons?.[index] || "—", + })), + ] + : fallback?.neighborhoods || [], + tips, + }; +} + export default function CityGuidePage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = use(params); - const { t } = useTranslation("cityGuide"); + const [loading, setLoading] = useState(true); + const [apiError, setApiError] = useState(false); + const [apiCity, setApiCity] = useState(null); + const [apiDetail, setApiDetail] = useState(null); - const city = CITY_GUIDES.find((c) => c.slug === slug); + useEffect(() => { + let cancelled = false; + apiFetch<{ + city?: CityApiRecord; + detail?: CityDetailRecord | null; + }>(`/api/cities/${slug}`) + .then((data) => { + if (cancelled) return; + setApiCity(data.city || null); + setApiDetail(data.detail || null); + setApiError(false); + }) + .catch(() => { + if (!cancelled) setApiError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [slug]); + + const city = useMemo( + () => mergeCityGuide(slug, apiCity, apiDetail), + [slug, apiCity, apiDetail] + ); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } if (!city) { return ( @@ -181,6 +305,11 @@ export default function CityGuidePage({ params }: { params: Promise<{ slug: stri
+ {apiError && ( +

+ 城市数据暂时无法同步,当前展示本地指南内容。 +

+ )}

{city.descriptionZh}

diff --git a/app/[locale]/dashboard/page.tsx b/app/[locale]/dashboard/page.tsx index c310633..82d61b9 100644 --- a/app/[locale]/dashboard/page.tsx +++ b/app/[locale]/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useTranslation } from "@/i18n/navigation"; import CityCard from "@/app/components/CityCard"; import CityModal from "@/app/components/CityModal"; @@ -110,21 +110,28 @@ export default function DashboardPage() { .catch(() => setRoutes([])); }, []); - const fallbackRecommendations = useMemo(() => { - return [...baseCities] - .filter((city) => city.costPerMonth <= budget * 1.25 && city.internetSpeed >= internet * 0.8) - .map((city) => ({ - ...city, - matchScore: Math.round(city.overallScore * 20 + Math.max(0, 20 - city.costPerMonth / 500)), - matchReasons: [ - city.costPerMonth <= budget ? "预算内" : "略超预算", - city.internetSpeed >= internet ? "网络达标" : "需确认住处网络", - city.nomadsNow >= 300 ? "社区活跃" : "适合安静工作", - ], - })) - .sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0)) - .slice(0, 12); - }, [baseCities, budget, internet]); + const buildFallbackRecommendations = useCallback( + (cities: RecommendedCity[]) => + [...cities] + .filter((city) => city.costPerMonth <= budget * 1.25 && city.internetSpeed >= internet * 0.8) + .map((city) => ({ + ...city, + matchScore: Math.round(city.overallScore * 20 + Math.max(0, 20 - city.costPerMonth / 500)), + matchReasons: [ + city.costPerMonth <= budget ? "预算内" : "略超预算", + city.internetSpeed >= internet ? "网络达标" : "需确认住处网络", + city.nomadsNow >= 300 ? "社区活跃" : "适合安静工作", + ], + })) + .sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0)) + .slice(0, 12), + [budget, internet] + ); + + const fallbackRecommendations = useMemo( + () => buildFallbackRecommendations(baseCities), + [baseCities, buildFallbackRecommendations] + ); useEffect(() => { let cancelled = false; @@ -138,12 +145,14 @@ export default function DashboardPage() { } }) .catch(() => { - if (!cancelled) setRecommendedCities(fallbackRecommendations); + if (!cancelled) { + setRecommendedCities(buildFallbackRecommendations(baseCities)); + } }); return () => { cancelled = true; }; - }, [budget, climate, fallbackRecommendations, internet, selectedTags]); + }, [baseCities, budget, buildFallbackRecommendations, climate, internet, selectedTags]); const displayCities = recommendedCities.length ? recommendedCities : fallbackRecommendations; const bestCity = displayCities[0]; diff --git a/app/[locale]/digital/join/page.tsx b/app/[locale]/digital/join/page.tsx index 80213ea..1f8c43c 100644 --- a/app/[locale]/digital/join/page.tsx +++ b/app/[locale]/digital/join/page.tsx @@ -7,7 +7,6 @@ import { redirectToPay, getDeviceFromEnv, } from "@/app/digital/lib/payment"; -import type { PayEnv } from "@/app/digital/lib/payment"; import { getStoredToken } from "@/app/digital/lib/pocketbase"; import AuthModal from "@/app/digital/components/AuthModal"; @@ -53,7 +52,6 @@ export default function JoinPage() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [payRedirecting, setPayRedirecting] = useState(false); - const [payEnv, setPayEnv] = useState(null); const [authOpen, setAuthOpen] = useState(false); const checkAuth = useCallback(async () => { @@ -73,12 +71,6 @@ export default function JoinPage() { } }, []); - useEffect(() => { - if (typeof window === "undefined") return; - const env = getPayEnv(); - setPayEnv(env); - }, []); - const set = (key: keyof FormData, value: string) => setForm((prev) => ({ ...prev, [key]: value })); diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index 5f1b849..f472031 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -8,9 +8,8 @@ import { getDeviceFromEnv, } from "@/app/lib/payment"; import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; -import type { PayEnv } from "@/app/lib/payment"; import AuthModal from "@/app/components/AuthModal"; -import { apiUrl } from "@/app/lib/api-client"; +import { apiFetch, fetchAuthMe } from "@/app/lib/api-client"; const genderOptions = ["男", "女"]; const singleOptions = ["单身", "恋爱中", "已婚"]; @@ -55,7 +54,6 @@ export default function JoinPage() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [payRedirecting, setPayRedirecting] = useState(false); - const [payEnv, setPayEnv] = useState(null); const [authOpen, setAuthOpen] = useState(false); const [pendingSubmit, setPendingSubmit] = useState(false); const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false); @@ -67,14 +65,13 @@ export default function JoinPage() { } const checkExisting = async () => { try { - const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" }); - const meData = await meRes.json().catch(() => ({})); - if (!meRes.ok || !meData?.user) { - return; - } - const statusRes = await fetch(apiUrl("/api/join/status"), { credentials: "include", cache: "no-store" }); - const statusData = await statusRes.json().catch(() => ({})); - if (statusRes.ok && statusData?.hasRecord && statusData?.isComplete) { + const meData = await fetchAuthMe(); + if (!meData?.user) return; + const statusData = await apiFetch<{ hasRecord?: boolean; isComplete?: boolean }>( + "/api/join/status", + { cache: "no-store" } + ); + if (statusData?.hasRecord && statusData?.isComplete) { setIsAlreadyRecorded(true); setSubmitted(true); } @@ -94,11 +91,6 @@ export default function JoinPage() { window.location.replace(url); }); - useEffect(() => { - if (typeof window === "undefined") return; - setPayEnv(getPayEnv()); - }, []); - const set = (key: keyof FormData, value: string) => setForm((prev) => ({ ...prev, [key]: value })); @@ -106,14 +98,11 @@ export default function JoinPage() { setError(null); setSubmitting(true); try { - const res = await fetch(apiUrl("/api/join"), { + const data = await apiFetch<{ ok?: boolean; user_id?: string; error?: string }>("/api/join", { 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) { + if (!data?.ok) { throw new Error(data?.error || "提交失败,请稍后重试"); } const userId = data.user_id; @@ -122,8 +111,7 @@ export default function JoinPage() { return; } // 再次确认 VIP,防止 handleAuthSuccess 等路径绕过 - const vipRes2 = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" }); - const vipData2 = await vipRes2.json(); + const vipData2 = await apiFetch<{ vip?: boolean }>("/api/vip/check", { cache: "no-store" }); if (vipData2?.vip) { setIsVipSubmit(true); setSubmitted(true); @@ -153,15 +141,13 @@ export default function JoinPage() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" }); - const meData = await meRes.json(); + const meData = await fetchAuthMe(); if (!meData?.user) { setPendingSubmit(true); setAuthOpen(true); return; } - const vipRes = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" }); - const vipData = await vipRes.json(); + const vipData = await apiFetch<{ vip?: boolean }>("/api/vip/check", { cache: "no-store" }); if (vipData?.vip) { setIsVipSubmit(true); } @@ -394,12 +380,11 @@ export default function JoinPage() { try { const fd = new FormData(); fd.append("file", file); - const res = await fetch(apiUrl("/api/upload-media"), { - method: "POST", - body: fd, - }); - const data = await res.json(); - if (!res.ok || !data?.ok) { + const data = await apiFetch<{ ok?: boolean; url?: string; error?: string }>( + "/api/upload-media", + { method: "POST", body: fd } + ); + if (!data?.ok) { throw new Error(data?.error || "上传失败"); } setMedia((m) => (m ? { ...m, url: data.url } : null)); diff --git a/app/[locale]/meetups/page.tsx b/app/[locale]/meetups/page.tsx index 84fc869..3b7d116 100644 --- a/app/[locale]/meetups/page.tsx +++ b/app/[locale]/meetups/page.tsx @@ -739,18 +739,37 @@ export default function MeetupsPage() { const c = getCopy(locale); const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming"); const [modeFilter, setModeFilter] = useState("all"); - const [meetups, setMeetups] = useState(allMeetups); + const [meetups, setMeetups] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [selectedMeetup, setSelectedMeetup] = useState(null); const [viewer, setViewer] = useState({ loaded: false, user: null, vip: false }); useEffect(() => { + let cancelled = false; apiFetch<{ items: Array & { id: string; status?: string }> }>("/api/meetups") .then((data) => { + if (cancelled) return; if (data.items?.length) { setMeetups(data.items.map(mapMeetup)); + setLoadError(false); + } else { + setMeetups(allMeetups); + setLoadError(true); } }) - .catch(() => setMeetups(allMeetups)); + .catch(() => { + if (!cancelled) { + setMeetups(allMeetups); + setLoadError(true); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -865,13 +884,33 @@ export default function MeetupsPage() { {activeTab === "upcoming" ? c.upcomingCountSuffix : c.previousCountSuffix}

+ {loadError && !loading && ( +

+ {locale === "zh" ? "活动数据暂时无法同步,当前展示示例活动。" : "Unable to sync meetups; showing sample events."} +

+ )} +
- {displayedMeetups.map((meetup) => ( - - ))} + {loading + ? Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ )) + : displayedMeetups.map((meetup) => ( + + ))}
- {displayedMeetups.length === 0 && ( + {!loading && displayedMeetups.length === 0 && (
🍹

diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index ecc29dd..c134b30 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, type CSSProperties } from "react"; import { Link, useLocale } from "@/i18n/navigation"; import { useTranslation } from "@/i18n/navigation"; import HeroSection from "../components/HeroSection"; @@ -289,6 +289,7 @@ export default function Home() {

); + const rowStyle = { "--right-row": index + 1 } as CSSProperties; return isExternal ? ( {content} @@ -305,14 +306,14 @@ export default function Home() { key={item.slug} href={meta.href} className="right-item block" - style={{ gridColumn: "-2 / -1", gridRow: index + 1 }} + style={rowStyle} > {content} ); })} - +
{t("joinCommunity")}
💬 @@ -328,12 +329,12 @@ export default function Home() {
- +
{t("newMembers")}
- {memberPhotos.map((m) => ( + {memberPhotos.map((m, i) => ( {m.name} - +
{t("latestEvents")}
{homeMeetups.slice(0, 3).map((meetup) => ( @@ -359,9 +360,9 @@ export default function Home() {
))}
- {memberPhotos.slice(0, 5).map((m) => ( + {memberPhotos.slice(0, 5).map((m, i) => ( >, successEmail: string) => { pbSaveAuth(result.token, result.record); - try { - await fetch(apiUrl("/api/auth/sync-session"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: result.token, record: result.record }), - credentials: "include", - }); - } catch { - /* ignore */ - } + await syncAuthSession(result.token, result.record as Record); onSuccess(successEmail); onClose(); setEmail(""); diff --git a/app/components/CityCard.tsx b/app/components/CityCard.tsx index 457d624..4c58776 100644 --- a/app/components/CityCard.tsx +++ b/app/components/CityCard.tsx @@ -3,8 +3,7 @@ import { memo, useState } from "react"; import { City } from "../data/cities"; import { getCitySocialSummary } from "../data/city-social"; -import { Link, useTranslation } from "@/i18n/navigation"; -import { cityVolunteerHref } from "@/app/lib/city-slugs"; +import { useTranslation } from "@/i18n/navigation"; function ratingToPercent(rating: string): number { const m: Record = { @@ -134,13 +133,6 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) { {/* Bottom: temp + cost + nomads */}
- e.stopPropagation()} - className="shrink-0 rounded-full bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#ff4d4f] shadow-sm transition-colors hover:bg-white/90 sm:text-[11px]" - > - {t("volunteerRecruitment")} - 👥 {city.nomadsNow}{t("nomadsHere")} diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 15c9428..2f557ba 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -9,8 +9,8 @@ import { redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; -import type { PayChannel, PayEnv } from "@/app/lib/payment"; -import { apiUrl } from "@/app/lib/api-client"; +import type { PayChannel } from "@/app/lib/payment"; +import { apiFetch, fetchAuthMe, syncAuthSession } from "@/app/lib/api-client"; import { mediaUrl } from "@/app/lib/media"; import { avatarForIndex } from "@/app/data/avatars"; @@ -74,13 +74,8 @@ export default function HeroSection() { useEffect(() => { const checkAuth = async () => { - try { - const res = await fetch(apiUrl("/api/auth/me"), { credentials: "include" }); - const data = await res.json(); - setIsLoggedIn(!!data?.user); - } catch { - setIsLoggedIn(false); - } + const data = await fetchAuthMe(); + setIsLoggedIn(!!data?.user); }; checkAuth(); const onAuth = () => checkAuth(); @@ -111,15 +106,12 @@ export default function HeroSection() { return; } } - const checkRes = await fetch(apiUrl("/api/meetup/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 || (locale === "zh" ? "操作失败" : "Operation failed")); + const checkData = await apiFetch<{ ok?: boolean; exists?: boolean; vip?: boolean; error?: string }>( + "/api/meetup/check-user", + { method: "POST", body: JSON.stringify({ email: trimmed }) } + ); + if (!checkData?.ok) { + throw new Error(checkData?.error || (locale === "zh" ? "操作失败" : "Operation failed")); } if (checkData.exists) { checkUserCacheRef.current = { email: trimmed, data: { exists: true, vip: !!checkData.vip }, ts: Date.now() }; @@ -138,28 +130,24 @@ export default function HeroSection() { isPrivateDevHost(window.location.hostname); let user_id = buildPendingUserId(trimmed); if (shouldPrecreateUser) { - const ensureRes = await fetch(apiUrl("/api/meetup/ensure-user"), { + const ensureData = await apiFetch<{ + ok?: boolean; + user_id?: string; + token?: string; + record?: Record; + error?: string; + }>("/api/meetup/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) { + if (!ensureData?.ok || !ensureData?.user_id) { throw new Error( - ensureData?.error || - ensureData?.detail || - (locale === "zh" ? "创建用户失败" : "Failed to create user") + ensureData?.error || (locale === "zh" ? "创建用户失败" : "Failed to create user") ); } user_id = String(ensureData.user_id).trim(); if (ensureData?.token && ensureData?.record) { - await fetch(apiUrl("/api/auth/sync-session"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: ensureData.token, record: ensureData.record }), - credentials: "include", - }); + await syncAuthSession(ensureData.token, ensureData.record); } } const returnUrl = diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx index 165e1ff..45f9ce4 100644 --- a/app/components/JoinModal.tsx +++ b/app/components/JoinModal.tsx @@ -8,8 +8,8 @@ import { redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; -import type { PayChannel, PayEnv } from "@/app/lib/payment"; -import { apiUrl } from "@/app/lib/api-client"; +import type { PayChannel } from "@/app/lib/payment"; +import { apiFetch, syncAuthSession } from "@/app/lib/api-client"; import { googleLogin } from "@/app/lib/pocketbase"; import GoogleLoginButton from "./GoogleLoginButton"; @@ -39,16 +39,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly const saveAuth = useCallback(async (token: string, record: Record) => { const { pbSaveAuth } = await import("@/app/lib/pocketbase"); pbSaveAuth(token, record as { id: string; email: string; [key: string]: unknown }); - try { - await fetch(apiUrl("/api/auth/sync-session"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, record }), - credentials: "include", - }); - } catch { - /* local auth is already saved; backend cookie will be retried by the navbar */ - } + await syncAuthSession(token, record); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:updated")); } @@ -82,14 +73,17 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly setError(null); setLoading(true); try { - const res = await fetch(apiUrl("/api/meetup/ensure-user"), { + const data = await apiFetch<{ + ok?: boolean; + token?: string; + record?: Record; + user_id?: string; + error?: string; + }>("/api/meetup/ensure-user", { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: em }), - credentials: "include", }); - const data = await res.json(); - if (!res.ok || !data?.ok) { + if (!data?.ok) { throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed")); } await saveAuth(data.token, data.record); diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index db408a1..6060992 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -2,8 +2,8 @@ import { useState, useEffect, useCallback } from "react"; import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation"; -import { getStoredUserEmail, getStoredUser, getStoredToken, pbLogout } from "@/app/lib/pocketbase"; -import { apiUrl } from "@/app/lib/api-client"; +import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase"; +import { resolveAuthUser } from "@/app/lib/api-client"; import { getNavLinks } from "@/config"; import AuthModal from "./AuthModal"; import ThemeToggle from "./ThemeToggle"; @@ -26,37 +26,13 @@ export default function NavbarComponent() { const navLinks = getNavLinks(); const fetchAuth = useCallback(async () => { - try { - let res = await fetch(apiUrl("/api/auth/me"), { credentials: "include", cache: "no-store" }); - let data = await res.json(); - 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); - setUserVip(!!data.user.vip); - return; - } - const token = getStoredToken(); - const record = getStoredUser(); - if (token && record?.id && record?.email) { - const syncRes = await fetch(apiUrl("/api/auth/sync-session"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, record }), - credentials: "include", - }); - if (syncRes.ok) { - res = await fetch(apiUrl("/api/auth/me"), { credentials: "include", cache: "no-store" }); - data = await res.json(); - if (data?.user && data?.token) { - setUserEmail(data.user.email); - setUserVip(!!data.user.vip); - return; - } - } - } - } catch { - /* ignore */ + const session = await resolveAuthUser(); + if (session) { + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(session.token, { id: session.user.id, email: session.user.email }); + setUserEmail(session.user.email); + setUserVip(!!session.user.vip); + return; } setUserEmail(getStoredUserEmail()); setUserVip(false); @@ -153,7 +129,15 @@ export default function NavbarComponent() {
- ) : null} + ) : ( + + )}