This commit is contained in:
eric
2026-06-07 22:50:42 -05:00
parent d27807e504
commit 6d479c9f94
23 changed files with 435 additions and 277 deletions

View File

@@ -8,7 +8,7 @@ import {
pbSaveAuth,
} from "@/app/lib/pocketbase";
import { useTranslation } from "@/i18n/navigation";
import { apiUrl } from "@/app/lib/api-client";
import { syncAuthSession } from "@/app/lib/api-client";
import GoogleLoginButton from "./GoogleLoginButton";
interface AuthModalProps {
@@ -28,16 +28,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
const finishAuth = useCallback(
async (result: Awaited<ReturnType<typeof emailLogin>>, 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<string, unknown>);
onSuccess(successEmail);
onClose();
setEmail("");

View File

@@ -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<string, number> = {
@@ -134,13 +133,6 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{/* Bottom: temp + cost + nomads */}
<div>
<div className="mb-1 flex flex-wrap items-center gap-1">
<Link
href={cityVolunteerHref(city)}
onClick={(e) => 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")}
</Link>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
👥 {city.nomadsNow}{t("nomadsHere")}
</span>

View File

@@ -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<string, unknown>;
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 =

View File

@@ -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<string, unknown>) => {
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<string, unknown>;
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);

View File

@@ -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() {
</span>
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
</div>
) : null}
) : (
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
)}
<NotificationBell />
<button
type="button"
@@ -169,7 +153,7 @@ export default function NavbarComponent() {
href="/join"
className="text-xs sm:text-sm bg-[#ff4d4f] text-white px-3 sm:px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium whitespace-nowrap"
>
{t("explore")}
{tNav("join")}
</Link>
<button

View File

@@ -16,6 +16,7 @@ export default function ThemeToggle() {
aria-label={isDark ? t("themeDark") : t("themeLight")}
title={isDark ? t("themeDark") : t("themeLight")}
>
<span className="sr-only">{isDark ? t("themeDark") : t("themeLight")}</span>
{isDark ? (
<svg
className="h-5 w-5"