's'
This commit is contained in:
46
.env.example
46
.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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ export function getPayEnv(): PayEnv {
|
||||
* 根据环境推荐支付通道
|
||||
* 所有支付默认微信支付,支付宝暂不提供
|
||||
*/
|
||||
export function getRecommendedChannel(_env: PayEnv): PayChannel {
|
||||
export function getRecommendedChannel(env: PayEnv): PayChannel {
|
||||
void env;
|
||||
return "wxpay";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,10 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"node_modules/**",
|
||||
"backend/**",
|
||||
".codex/**",
|
||||
"data/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"pricing": "Pricing",
|
||||
"gigs": "Gigs",
|
||||
"report": "Report",
|
||||
"messages": "Messages"
|
||||
"messages": "Messages",
|
||||
"join": "Join"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Chinese Digital Nomad Community",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"pricing": "会员",
|
||||
"gigs": "赏金",
|
||||
"report": "年报",
|
||||
"messages": "站内消息"
|
||||
"messages": "站内消息",
|
||||
"join": "加入社群"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "中国数字游民社区",
|
||||
|
||||
Reference in New Issue
Block a user