This commit is contained in:
eric
2026-06-06 04:29:46 -05:00
parent 88aa96a2a1
commit 41493c35ca
50 changed files with 4666 additions and 416 deletions

View File

@@ -8,6 +8,7 @@ import {
pbSaveAuth,
} from "@/app/lib/pocketbase";
import { useTranslation } from "@/i18n/navigation";
import { apiUrl } from "@/app/lib/api-client";
interface AuthModalProps {
isOpen: boolean;
@@ -48,7 +49,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
}
pbSaveAuth(result.token, result.record);
try {
await fetch("/api/auth/sync-session", {
await fetch(apiUrl("/api/auth/sync-session"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),

View File

@@ -3,7 +3,9 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { City } from "@/app/data/cities";
import { apiFetch } from "@/app/lib/api-client";
const CITY_COORDS: Record<string, [number, number]> = {
: [25.7, 100.2],
@@ -62,18 +64,34 @@ interface CityModalProps {
onClose: () => void;
}
interface CityDetailData {
detail?: Record<string, unknown> | null;
content?: Array<Record<string, unknown>>;
meetups?: Array<Record<string, unknown>>;
discussions?: Array<Record<string, unknown>>;
}
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
const [activeTab, setActiveTab] = useState("scores");
const [mounted, setMounted] = useState(false);
const [favorited, setFavorited] = useState(false);
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!city || !isOpen) return;
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
apiFetch<CityDetailData & { ok: boolean }>(`/api/cities/${slug}`)
.then((data) => setDetailData(data))
.catch(() => setDetailData(null));
}, [city, isOpen]);
if (!isOpen || !city || !mounted) return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + Math.floor(Math.random() * 500);
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
@@ -249,12 +267,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
</div>
</div>
) : (
<div className="py-6 sm:py-8 text-center text-gray-500 dark:text-gray-400">
<p className="text-lg">{city.icon}</p>
<p className="mt-2 text-sm sm:text-base">
{tabs.find((x) => x.id === activeTab) ? t(tabs.find((x) => x.id === activeTab)!.labelKey) : activeTab}{t("comingSoon")}
</p>
</div>
<CityTabContent activeTab={activeTab} city={city} data={detailData} />
)}
</div>
</div>
@@ -296,3 +309,287 @@ function getAcLabel(pct: number, t: (k: string) => string): string {
if (pct >= 70) return t("scores.high");
return t("scores.normal");
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? (value.filter((x) => x && typeof x === "object") as Array<Record<string, unknown>>) : [];
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function CityTabContent({
activeTab,
city,
data,
}: {
activeTab: string;
city: City;
data: CityDetailData | null;
}) {
const detail = asRecord(data?.detail);
const tab = asRecord(detail[activeTab]);
const content = data?.content || [];
const meetups = data?.meetups || [];
const discussions = data?.discussions || [];
if (activeTab === "guide") {
return (
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
<section className="space-y-4">
<Panel title="城市落地指南">
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(tab.summary || city.description)}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<InfoCard label="工作配置" value={String(tab.workSetup || `${city.internetSpeed}Mbps 网络,建议先短住实测。`)} />
<InfoCard label="适合人群" value={asStringArray(tab.bestFor).join(" · ") || city.tags.join(" · ")} />
</div>
</Panel>
<Panel title="30 天落地清单">
<CheckList items={asStringArray(tab.arrivalChecklist)} />
</Panel>
</section>
<section className="space-y-4">
<RelatedContent items={content} />
<Panel title="同城活动">
<div className="space-y-3">
{meetups.map((meetup) => (
<Link key={String(meetup.id)} href="/meetups" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
<span className="font-semibold">{String(meetup.venue || "活动地点待定")}</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">{String(meetup.date || "")} · {String(meetup.rsvpCount || 0)} </span>
</Link>
))}
{meetups.length === 0 && (
<Link href="/meetups/host" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
</Link>
)}
</div>
</Panel>
</section>
</div>
);
}
if (activeTab === "pros") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="优点">
<CheckList items={asStringArray(tab.pros)} tone="good" />
</Panel>
<Panel title="需要注意">
<CheckList items={asStringArray(tab.cons)} tone="warn" />
</Panel>
</div>
);
}
if (activeTab === "reviews") {
const items = asArray(tab.items);
return (
<div className="space-y-4">
{items.map((review, i) => (
<Panel key={i} title={`${review.author || "社区成员"} · ${review.role || "数字游民"}`}>
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(review.text || "")}</p>
<p className="mt-3 text-sm text-[#ff4d4f]"> {String(review.score || city.overallScore)}/5</p>
</Panel>
))}
</div>
);
}
if (activeTab === "cost") {
const breakdown = asArray(tab.breakdown);
const total = Number(tab.monthlyTotal || city.costPerMonth);
return (
<Panel title={`月生活成本:¥${total.toLocaleString()}`}>
<div className="space-y-3">
{breakdown.map((item) => {
const amount = Number(item.amount || 0);
return (
<div key={String(item.label)}>
<div className="mb-1 flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{String(item.label)}</span>
<span>¥{amount.toLocaleString()}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div className="h-full rounded-full bg-[#ff4d4f]" style={{ width: `${Math.min(100, (amount / total) * 100)}%` }} />
</div>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.tip || "成本数据会随社区反馈持续校准。")}</p>
</Panel>
);
}
if (activeTab === "people") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title={`${Number(tab.nomadsNow || city.nomadsNow).toLocaleString()} 位游民正在这里`}>
<div className="space-y-3">
{asArray(tab.personas).map((item) => (
<InfoCard key={String(item.name)} label={String(item.name)} value={`${String(item.percent)}%`} />
))}
</div>
</Panel>
<Panel title="相关访谈与工具">
<RelatedContent items={content} compact />
</Panel>
</div>
);
}
if (activeTab === "chat") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title="同城频道">
<div className="space-y-3">
{asArray(tab.channels).map((channel) => (
<InfoCard key={String(channel.name)} label={String(channel.name)} value={`${String(channel.members)} 成员 · ${String(channel.status)}`} />
))}
</div>
<Link href="/join" className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white"></Link>
</Panel>
<Panel title="同城讨论">
<div className="space-y-3">
{discussions.map((topic) => (
<Link key={String(topic.id)} href="/discuss" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
{String(topic.title)}
</Link>
))}
</div>
</Panel>
</div>
);
}
if (activeTab === "photos") {
const photos = asStringArray(tab.items);
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{photos.map((src) => (
<img key={src} src={src} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
))}
</div>
);
}
if (activeTab === "weather") {
return (
<Panel title="天气与季节">
<div className="grid gap-3 sm:grid-cols-3">
<InfoCard label="当前温度" value={`${String(tab.temperature || city.temperature)}°C`} />
<InfoCard label="湿度" value={`${String(tab.humidity || city.humidity)}%`} />
<InfoCard label="推荐月份" value={asStringArray(tab.bestMonths).join("、")} />
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.note || "真实天气接口接入后会显示未来 7 天趋势。")}</p>
</Panel>
);
}
if (activeTab === "trends") {
return (
<Panel title="社群增长趋势">
<div className="flex h-48 items-end gap-3">
{asArray(tab.growth).map((point) => {
const value = Number(point.value || 0);
return (
<div key={String(point.month)} className="flex flex-1 flex-col items-center gap-2">
<div className="w-full rounded-t-lg bg-[#ff4d4f]" style={{ height: `${Math.max(18, Math.min(100, value / 12))}%` }} />
<span className="text-xs text-gray-400">{String(point.month)}</span>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.insight || "趋势数据来自社区成员位置和活动报名聚合。")}</p>
</Panel>
);
}
if (activeTab === "demographics") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="年龄结构">
{asArray(tab.age).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
<Panel title="职业结构">
{asArray(tab.work).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
</div>
);
}
return <RelatedContent items={content} />;
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-3 font-bold text-gray-900 dark:text-gray-100">{title}</h3>
{children}
</section>
);
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="mb-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800">
<p className="text-xs text-gray-400">{label}</p>
<p className="mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{value}</p>
</div>
);
}
function CheckList({ items, tone = "good" }: { items: string[]; tone?: "good" | "warn" }) {
const icon = tone === "good" ? "✓" : "!";
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item} className="flex gap-2 rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span className={tone === "good" ? "text-emerald-500" : "text-amber-500"}>{icon}</span>
<span>{item}</span>
</div>
))}
</div>
);
}
function RelatedContent({ items, compact = false }: { items: Array<Record<string, unknown>>; compact?: boolean }) {
const hrefFor = (item: Record<string, unknown>) => {
const type = String(item.type || "");
const slug = String(item.slug || "");
if (type === "ebook") return `/ebooks/${slug}`;
if (type === "video") return `/videos/${slug}`;
if (type === "app") return "/app";
return String(item.targetUrl || "https://digital.hackrobot.cn/zh");
};
return (
<Panel title="关联内容">
<div className="space-y-3">
{items.map((item) => {
const href = hrefFor(item);
const external = href.startsWith("http");
const body = (
<div className="flex gap-3 rounded-xl bg-gray-50 p-3 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
{!compact && <img src={String(item.coverImage || "")} alt="" className="h-14 w-20 rounded-lg object-cover" />}
<div className="min-w-0">
<p className="line-clamp-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{String(item.title)}</p>
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{String(item.subtitle || "")}</p>
</div>
</div>
);
return external ? (
<a key={String(item.slug)} href={href} target="_blank" rel="noopener noreferrer">{body}</a>
) : (
<Link key={String(item.slug)} href={href}>{body}</Link>
);
})}
</div>
</Panel>
);
}

View File

@@ -10,6 +10,7 @@ import {
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import { apiUrl } from "@/app/lib/api-client";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
@@ -72,7 +73,7 @@ export default function HeroSection() {
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const res = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
const data = await res.json();
setIsLoggedIn(!!data?.user);
} catch {
@@ -108,7 +109,7 @@ export default function HeroSection() {
return;
}
}
const checkRes = await fetch("/api/meetup/check-user", {
const checkRes = await fetch(apiUrl("/api/meetup/check-user"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
@@ -135,7 +136,7 @@ export default function HeroSection() {
isPrivateDevHost(window.location.hostname);
let user_id = buildPendingUserId(trimmed);
if (shouldPrecreateUser) {
const ensureRes = await fetch("/api/meetup/ensure-user", {
const ensureRes = await fetch(apiUrl("/api/meetup/ensure-user"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
@@ -150,11 +151,11 @@ export default function HeroSection() {
);
}
if (ensureData.is_new) {
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
}
user_id = String(ensureData.user_id).trim();
if (ensureData?.token && ensureData?.record) {
await fetch("/api/auth/sync-session", {
await fetch(apiUrl("/api/auth/sync-session"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: ensureData.token, record: ensureData.record }),

View File

@@ -9,6 +9,7 @@ import {
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import { apiUrl } from "@/app/lib/api-client";
interface JoinModalProps {
isOpen: boolean;
@@ -44,7 +45,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
setError(null);
setLoading(true);
try {
const res = await fetch("/api/meetup/ensure-user", {
const res = await fetch(apiUrl("/api/meetup/ensure-user"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: em, password: password || undefined }),
@@ -55,11 +56,11 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
}
if (data.is_new) {
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
}
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, data.record);
await fetch("/api/auth/sync-session", {
await fetch(apiUrl("/api/auth/sync-session"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: data.token, record: data.record }),

View File

@@ -3,6 +3,7 @@
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 { getNavLinks } from "@/config";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
@@ -25,7 +26,7 @@ export default function NavbarComponent() {
const fetchAuth = useCallback(async () => {
try {
let res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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");
@@ -37,14 +38,14 @@ export default function NavbarComponent() {
const token = getStoredToken();
const record = getStoredUser();
if (token && record?.id && record?.email) {
const syncRes = await fetch("/api/auth/sync-session", {
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("/api/auth/me", { credentials: "include", cache: "no-store" });
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);