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

@@ -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

View File

@@ -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<CityApiRecord | null>(null);
const [apiDetail, setApiDetail] = useState<CityDetailRecord | null>(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 (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="max-w-[1200px] mx-auto px-4 sm:px-6 py-6 sm:py-8">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="mt-4 overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-gray-900">
<div className="h-40 animate-pulse bg-gray-200 dark:bg-gray-800" />
<div className="space-y-4 p-6 sm:p-8">
<div className="h-6 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-800/80" />
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-100 dark:bg-gray-800/80" />
</div>
</div>
</main>
<Footer />
</div>
);
}
if (!city) {
return (
@@ -181,6 +305,11 @@ export default function CityGuidePage({ params }: { params: Promise<{ slug: stri
</div>
<div className="p-6 sm:p-8">
{apiError && (
<p className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
</p>
)}
<p className="text-gray-600 dark:text-gray-400 text-base sm:text-lg mb-8">
{city.descriptionZh}
</p>

View File

@@ -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];

View File

@@ -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<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(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 }));

View File

@@ -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<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(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));

View File

@@ -739,18 +739,37 @@ export default function MeetupsPage() {
const c = getCopy(locale);
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
const [modeFilter, setModeFilter] = useState<ModeFilter>("all");
const [meetups, setMeetups] = useState<Meetup[]>(allMeetups);
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [selectedMeetup, setSelectedMeetup] = useState<Meetup | null>(null);
const [viewer, setViewer] = useState<ViewerState>({ loaded: false, user: null, vip: false });
useEffect(() => {
let cancelled = false;
apiFetch<{ items: Array<Partial<Meetup> & { 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}
</p>
{loadError && !loading && (
<p className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{locale === "zh" ? "活动数据暂时无法同步,当前展示示例活动。" : "Unable to sync meetups; showing sample events."}
</p>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
{displayedMeetups.map((meetup) => (
<MeetupCard key={meetup.id} meetup={meetup} locale={locale} viewer={viewer} onSelect={setSelectedMeetup} />
))}
{loading
? Array.from({ length: 6 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="h-[360px] animate-pulse overflow-hidden rounded-2xl bg-white shadow-sm dark:bg-gray-900"
>
<div className="h-28 bg-gray-200 dark:bg-gray-800" />
<div className="space-y-3 p-5">
<div className="h-6 w-2/3 rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-4 w-full rounded bg-gray-100 dark:bg-gray-800/80" />
<div className="h-4 w-5/6 rounded bg-gray-100 dark:bg-gray-800/80" />
</div>
</div>
))
: displayedMeetups.map((meetup) => (
<MeetupCard key={meetup.id} meetup={meetup} locale={locale} viewer={viewer} onSelect={setSelectedMeetup} />
))}
</div>
{displayedMeetups.length === 0 && (
{!loading && displayedMeetups.length === 0 && (
<div className="py-16 text-center sm:py-24">
<span className="mb-4 block text-5xl sm:text-6xl">🍹</span>
<p className="text-base text-gray-500 dark:text-gray-400 sm:text-lg">

View File

@@ -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() {
</div>
</>
);
const rowStyle = { "--right-row": index + 1 } as CSSProperties;
return isExternal ? (
<a
key={item.slug}
@@ -296,7 +297,7 @@ export default function Home() {
target="_blank"
rel="noopener noreferrer"
className="right-item block"
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
style={rowStyle}
>
{content}
</a>
@@ -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}
</Link>
);
})}
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 1 }}>
<Link href="/join" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 1 } as React.CSSProperties}>
<div className="right-item-header">{t("joinCommunity")}</div>
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
@@ -328,12 +329,12 @@ export default function Home() {
</div>
</Link>
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 2 }}>
<Link href="/dating" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 2 } as React.CSSProperties}>
<div className="right-item-header">{t("newMembers")}</div>
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
{memberPhotos.map((m) => (
{memberPhotos.map((m, i) => (
<img
key={m.name}
key={`${m.name}-${i}`}
src={mediaUrl(m.photo)}
alt={m.name}
title={m.name}
@@ -346,7 +347,7 @@ export default function Home() {
</div>
</Link>
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 3 }}>
<Link href="/meetups" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 3 } as React.CSSProperties}>
<div className="right-item-header">{t("latestEvents")}</div>
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
{homeMeetups.slice(0, 3).map((meetup) => (
@@ -359,9 +360,9 @@ export default function Home() {
</div>
))}
<div className="flex -space-x-1 mt-auto">
{memberPhotos.slice(0, 5).map((m) => (
{memberPhotos.slice(0, 5).map((m, i) => (
<img
key={m.name}
key={`${m.name}-meetup-${i}`}
src={mediaUrl(m.photo)}
alt=""
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"

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"

View File

@@ -2,10 +2,10 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
@@ -23,26 +23,28 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
try {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === "dark" || stored === "light") return stored;
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch {
/* ignore */
return "light";
}
return "light";
}
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
applyTheme(theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
@@ -58,14 +60,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setThemeState((prev) => (prev === "light" ? "dark" : "light"));
}, []);
const value: ThemeContextValue = {
theme,
setTheme,
toggleTheme,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}

View File

@@ -39,7 +39,8 @@ export function getPayEnv(): PayEnv {
* 根据环境推荐支付通道
* 所有支付默认微信支付,支付宝暂不提供
*/
export function getRecommendedChannel(_env: PayEnv): PayChannel {
export function getRecommendedChannel(env: PayEnv): PayChannel {
void env;
return "wxpay";
}

View File

@@ -23,10 +23,10 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-primary: #0ea5e9;
--color-primary-dark: #0284c7;
--color-accent: #f59e0b;
--color-accent-dark: #d97706;
--color-primary: #ff4d4f;
--color-primary-dark: #e04345;
--color-accent: #ff7a45;
--color-accent-dark: #e56a35;
--animate-scroll: digital-scroll 30s linear infinite;
--animate-scroll-reverse: digital-scroll 30s linear infinite reverse;
--animate-fade-in: digital-fade-in 0.6s ease-out;
@@ -122,35 +122,12 @@ html {
}
}
/* Reserve the first right-column rows for feature cards.
City cards keep normal auto-placement and fill the right column after these rows. */
.items-grid .right-item:nth-child(1) {
grid-column: -2 / -1;
grid-row: 1;
}
.items-grid .right-item:nth-child(2) {
grid-column: -2 / -1;
grid-row: 2;
}
.items-grid .right-item:nth-child(3) {
grid-column: -2 / -1;
grid-row: 3;
}
.items-grid .right-item:nth-child(4) {
grid-column: -2 / -1;
grid-row: 4;
}
.items-grid .right-item:nth-child(5) {
grid-column: -2 / -1;
grid-row: 5;
}
.items-grid .right-item:nth-child(6) {
grid-column: -2 / -1;
grid-row: 6;
}
.items-grid .right-item:nth-child(7) {
grid-column: -2 / -1;
grid-row: 7;
/* Right sidebar cards: only pin to the last column on large screens. */
@media (min-width: 1024px) {
.items-grid .right-item {
grid-column: -2 / -1;
grid-row: var(--right-row, auto);
}
}
/* ==================== City Card ==================== */
@@ -257,6 +234,9 @@ html {
.filter-btn:hover {
background: #f3f4f6;
}
.dark .filter-btn:hover {
background: #374151;
}
.filter-btn.active {
background: #111827;
color: white;

View File

@@ -14,27 +14,6 @@ export const metadata: Metadata = {
description: getSiteConfig().meta.description,
};
function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
);
}
export default function RootLayout({
children,
}: Readonly<{
@@ -42,8 +21,14 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`,
}}
/>
</head>
<body className="antialiased">
<ThemeScript />
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase";
export const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
export function apiUrl(path: string): string {
@@ -30,3 +32,69 @@ export async function apiFetch<T>(
}
return data as T;
}
export type AuthUser = {
id: string;
email: string;
vip?: boolean;
role?: string;
[key: string]: unknown;
};
export type AuthMeResponse = {
user?: AuthUser | null;
token?: string;
};
/** 读取当前登录用户;失败时返回 null不抛错 */
export async function fetchAuthMe(): Promise<AuthMeResponse | null> {
try {
return await apiFetch<AuthMeResponse>("/api/auth/me", { cache: "no-store" });
} catch {
return null;
}
}
/** 将本地 PocketBase token 同步到 httpOnly cookie */
export async function syncAuthSession(
token: string,
record: Record<string, unknown>
): Promise<boolean> {
try {
await apiFetch("/api/auth/sync-session", {
method: "POST",
body: JSON.stringify({ token, record }),
});
return true;
} catch {
return false;
}
}
/**
* 解析当前用户:优先 cookie 会话,其次本地 token 同步后再读。
* 返回 user + token若有未登录返回 null。
*/
export async function resolveAuthUser(): Promise<{
user: AuthUser;
token: string;
} | null> {
let data = await fetchAuthMe();
if (data?.user?.id && data?.token) {
return { user: data.user, token: data.token };
}
const token = getStoredToken();
const record = getStoredUser();
if (token && record?.id && record?.email) {
const synced = await syncAuthSession(token, record as Record<string, unknown>);
if (synced) {
data = await fetchAuthMe();
if (data?.user?.id && data?.token) {
return { user: data.user, token: data.token };
}
}
}
return null;
}

View File

@@ -114,7 +114,7 @@ export function MembersMap({
instanceRef.current?.dispose();
instanceRef.current = null;
};
}, [data, locale, onCityHover]);
}, [data, locale, dark, onCityHover]);
useEffect(() => {
if (!instanceRef.current || !ready) return;

View File

@@ -30,7 +30,8 @@ export function getPayEnv(): PayEnv {
}
export function getRecommendedChannel(env: PayEnv): PayChannel {
return "wxpay"; // 默认微信支付PC/H5/微信内均优先)
if (env === "wechat") return "wxpay";
return "wxpay";
}
export function mustUseWxPay(env: PayEnv): boolean {

View File

@@ -12,6 +12,10 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"node_modules/**",
"backend/**",
".codex/**",
"data/**",
]),
]);

View File

@@ -63,7 +63,8 @@
"pricing": "Pricing",
"gigs": "Gigs",
"report": "Report",
"messages": "Messages"
"messages": "Messages",
"join": "Join"
},
"hero": {
"badge": "Chinese Digital Nomad Community",

View File

@@ -63,7 +63,8 @@
"pricing": "会员",
"gigs": "赏金",
"report": "年报",
"messages": "站内消息"
"messages": "站内消息",
"join": "加入社群"
},
"hero": {
"badge": "中国数字游民社区",