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

@@ -3,11 +3,23 @@
import { useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface AssistantCity {
id: string;
slug?: string;
name: string;
costPerMonth?: number;
internetSpeed?: number;
overallScore?: number;
}
export default function AIPage() {
const { t } = useTranslation("ai");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [answer, setAnswer] = useState("");
const [cities, setCities] = useState<AssistantCity[]>([]);
const exampleQueries = [
"我预算每月800刀想亚洲热带有稳定网适合freelance写作推荐城市+理由",
@@ -15,11 +27,20 @@ export default function AIPage() {
"生成3月清迈行程计划",
];
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setTimeout(() => setLoading(false), 1500);
try {
const data = await apiFetch<{ answer: string; cities: AssistantCity[] }>("/api/ai/assistant", {
method: "POST",
body: JSON.stringify({ question: query }),
});
setAnswer(data.answer);
setCities(data.cities || []);
} finally {
setLoading(false);
}
};
return (
@@ -52,6 +73,25 @@ export default function AIPage() {
</button>
</form>
{(answer || cities.length > 0) && (
<div className="mt-6 rounded-2xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-800">
{answer && <p className="leading-7 text-gray-700 dark:text-gray-300">{answer}</p>}
{cities.length > 0 && (
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{cities.map((city) => (
<div key={city.id} className="rounded-xl bg-white p-3 text-sm shadow-sm dark:bg-gray-900">
<p className="font-semibold text-gray-900 dark:text-gray-100">{city.name}</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
¥{Number(city.costPerMonth || 0).toLocaleString()}/ · {city.internetSpeed || 0}Mbps
</p>
<p className="mt-1 text-xs text-[#ff4d4f]"> {city.overallScore || "-"}/5</p>
</div>
))}
</div>
)}
</div>
)}
<div className="mt-6 pt-6 border-t border-gray-100 dark:border-gray-800">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t("examples")}
@@ -72,7 +112,7 @@ export default function AIPage() {
</div>
<p className="mt-6 text-center text-xs text-gray-400 dark:text-gray-500">
{t("comingSoon")}
AI
</p>
</div>
<Footer />

73
app/[locale]/app/page.tsx Normal file
View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import Footer from "@/app/components/Footer";
interface ContentItem {
title: string;
titleEn?: string;
subtitle: string;
subtitleEn?: string;
description: string;
descriptionEn?: string;
coverImage: string;
mediaUrl: string;
chapters?: { title: string; icon?: string }[];
}
export default function AppLandingPage() {
const locale = useLocale();
const [item, setItem] = useState<ContentItem | null>(null);
useEffect(() => {
apiFetch<{ item: ContentItem }>("/api/content/nomadcna-app")
.then((data) => setItem(data.item))
.catch(() => setItem(null));
}, []);
const title = item ? (locale === "zh" ? item.title : item.titleEn || item.title) : "NomadCNA App";
const subtitle = item ? (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle) : "";
const description = item ? (locale === "zh" ? item.description : item.descriptionEn || item.description) : "";
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-16">
<section className="grid items-center gap-10 lg:grid-cols-2">
<div>
<p className="text-sm font-semibold text-[#ff4d4f]">Nomad OS Mobile</p>
<h1 className="mt-3 text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">{title}</h1>
<p className="mt-4 text-xl text-gray-500 dark:text-gray-400">{subtitle}</p>
<p className="mt-6 leading-8 text-gray-700 dark:text-gray-300">{description}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href={item?.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-6 py-3 font-semibold text-white hover:bg-[#ff7a45]">
</a>
<Link href="/map" className="rounded-full border border-gray-300 px-6 py-3 font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">
</Link>
</div>
</div>
<div className="relative mx-auto h-[560px] w-[280px] rounded-[2rem] border-[10px] border-gray-900 bg-gray-950 p-3 shadow-2xl">
<div className="h-full overflow-hidden rounded-[1.4rem] bg-white dark:bg-gray-900">
{item?.coverImage && <img src={item.coverImage} alt={title} className="h-40 w-full object-cover" />}
<div className="p-4">
<p className="text-xs font-semibold text-[#ff4d4f]"></p>
<h2 className="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100"> · 线</h2>
<div className="mt-4 space-y-3">
{(item?.chapters || []).map((feature) => (
<div key={feature.title} className="rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{feature.icon || "•"} {feature.title}
</div>
))}
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -1,17 +1,80 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { cities } from "@/app/data/cities";
import { useEffect, useMemo, useState } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import CityCard from "@/app/components/CityCard";
import CityModal from "@/app/components/CityModal";
import Footer from "@/app/components/Footer";
import type { City } from "@/app/data/cities";
import { cities as fallbackCities, type City } from "@/app/data/cities";
import { apiFetch } from "@/app/lib/api-client";
const TAGS = ["远程工作者", "创业者", "设计师", "开发者", "内容创作者", "带宠物"];
type RecommendedCity = City & {
matchScore?: number;
matchReasons?: string[];
idNumber?: number;
};
interface Meetup {
id: string;
city: string;
emoji?: string;
date?: string;
time?: string;
venue?: string;
rsvpCount?: number;
isUpcoming?: boolean;
}
interface NomadRoute {
id?: string;
slug: string;
title: string;
titleEn?: string;
durationDays: number;
budget: number;
description: string;
stops: string[];
cities?: RecommendedCity[];
}
const CITY_TAGS = ["便宜", "宜居", "文化", "美食", "高速网络", "友好社区", "海滨", "都市"];
const TIMEZONES = ["UTC+8 中国", "UTC+7 东南亚", "UTC+1 欧洲", "UTC-5 美东", "UTC-8 美西", "任意"];
const VISA_OPTIONS = ["免签优先", "落地签可", "需提前办", "任意"];
const CLIMATE_OPTIONS = [
{ value: "any", label: "任意气候" },
{ value: "mild", label: "温和" },
{ value: "warm", label: "偏暖" },
{ value: "cool", label: "偏凉" },
];
function mapCity(item: Partial<RecommendedCity>, index: number): RecommendedCity {
return {
...item,
id: Number(item.idNumber || item.id || index + 1),
name: item.name || "未知城市",
country: item.country || "中国",
countryEmoji: item.countryEmoji || "🇨🇳",
overallScore: Number(item.overallScore || 0),
costPerMonth: Number(item.costPerMonth || 0),
internetSpeed: Number(item.internetSpeed || 0),
safety: item.safety || "很好",
liked: item.liked || "很好",
temperature: Number(item.temperature || 0),
humidity: Number(item.humidity || 0),
nomadsNow: Number(item.nomadsNow || 0),
description: item.description || "",
gradientFrom: item.gradientFrom || "#ff4d4f",
gradientTo: item.gradientTo || "#ff7a45",
icon: item.icon || "📍",
tags: Array.isArray(item.tags) ? item.tags : [],
} as RecommendedCity;
}
function formatMeetupDate(date?: string, time?: string): string {
if (!date) return time || "时间待定";
const day = date.slice(5, 10).replace("-", "月") + "日";
return time ? `${day} ${time.slice(0, 5)}` : day;
}
export default function DashboardPage() {
const { t } = useTranslation("dashboard");
@@ -19,19 +82,83 @@ export default function DashboardPage() {
const [internet, setInternet] = useState(50);
const [timezone, setTimezone] = useState("UTC+8 中国");
const [visa, setVisa] = useState("任意");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [climate, setClimate] = useState("mild");
const [selectedTags, setSelectedTags] = useState<string[]>(["宜居"]);
const [cityModal, setCityModal] = useState<City | null>(null);
const [baseCities, setBaseCities] = useState<RecommendedCity[]>(
fallbackCities.map((city, index) => mapCity(city, index))
);
const [recommendedCities, setRecommendedCities] = useState<RecommendedCity[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [routes, setRoutes] = useState<NomadRoute[]>([]);
const [loading, setLoading] = useState(false);
const filteredCities = useMemo(() => {
return [...cities]
.filter((c) => c.costPerMonth <= budget * 1.2 && c.internetSpeed >= internet)
.sort((a, b) => b.overallScore - a.overallScore)
useEffect(() => {
apiFetch<{ items: RecommendedCity[] }>("/api/cities")
.then((data) => {
if (data.items?.length) {
setBaseCities(data.items.map(mapCity));
}
})
.catch(() => setBaseCities(fallbackCities.map((city, index) => mapCity(city, index))));
apiFetch<{ items: Meetup[] }>("/api/meetups")
.then((data) => setMeetups((data.items || []).slice(0, 8)))
.catch(() => setMeetups([]));
apiFetch<{ items: NomadRoute[] }>("/api/routes")
.then((data) => setRoutes(data.items || []))
.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);
}, [budget, internet]);
}, [baseCities, budget, internet]);
useEffect(() => {
let cancelled = false;
setLoading(true);
apiFetch<{ items: RecommendedCity[] }>("/api/recommendations/next-stop", {
method: "POST",
body: JSON.stringify({ budget, internet, climate, tags: selectedTags, limit: 12 }),
})
.then((data) => {
if (!cancelled) {
setRecommendedCities((data.items || []).map(mapCity));
}
})
.catch(() => {
if (!cancelled) setRecommendedCities(fallbackRecommendations);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [budget, climate, fallbackRecommendations, internet, selectedTags]);
const displayCities = recommendedCities.length ? recommendedCities : fallbackRecommendations;
const bestCity = displayCities[0];
const topCost = Math.max(...displayCities.slice(0, 6).map((city) => city.costPerMonth), 1);
const topNomads = Math.max(...displayCities.slice(0, 6).map((city) => city.nomadsNow), 1);
const upcomingMeetups = meetups.filter((meetup) => meetup.isUpcoming !== false).slice(0, 6);
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
prev.includes(tag) ? prev.filter((item) => item !== tag) : [...prev, tag]
);
};
@@ -42,9 +169,7 @@ export default function DashboardPage() {
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
{t("title")}
</h1>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t("subtitle")}
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">{t("subtitle")}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
@@ -59,7 +184,7 @@ export default function DashboardPage() {
max="15000"
step="500"
value={budget}
onChange={(e) => setBudget(Number(e.target.value))}
onChange={(event) => setBudget(Number(event.target.value))}
className="w-full accent-[#ff4d4f]"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@@ -73,11 +198,30 @@ export default function DashboardPage() {
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
onChange={(event) => setTimezone(event.target.value)}
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
{TIMEZONES.map((z) => (
<option key={z} value={z}>{z}</option>
{TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</select>
</div>
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={climate}
onChange={(event) => setClimate(event.target.value)}
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
{CLIMATE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
@@ -88,11 +232,13 @@ export default function DashboardPage() {
</label>
<select
value={visa}
onChange={(e) => setVisa(e.target.value)}
onChange={(event) => setVisa(event.target.value)}
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
{VISA_OPTIONS.map((v) => (
<option key={v} value={v}>{v}</option>
{VISA_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
@@ -107,22 +253,21 @@ export default function DashboardPage() {
max="150"
step="10"
value={internet}
onChange={(e) => setInternet(Number(e.target.value))}
onChange={(event) => setInternet(Number(event.target.value))}
className="w-full accent-[#ff4d4f]"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{internet} Mbps
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{internet} Mbps</p>
</div>
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t("tags")}
</label>
<div className="flex flex-wrap gap-2">
{TAGS.map((tag) => (
{CITY_TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
selectedTags.includes(tag)
@@ -141,67 +286,129 @@ export default function DashboardPage() {
🤖 {t("aiTip")}
</p>
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
80% 3
{bestCity
? `${bestCity.name} 当前匹配度最高:${(bestCity.matchReasons || []).join("、") || "综合分领先"}`
: `正在按 ${timezone} / ${visa} 重新计算。`}
</p>
</div>
</aside>
<main className="lg:col-span-2">
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
{t("search")}
</h2>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("search")}
</h2>
{loading && (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3" style={{ gridAutoRows: "160px" }}>
{filteredCities.map((city, i) => (
<div key={city.id} className="h-full min-h-[140px]">
{displayCities.map((city, index) => (
<div key={`${city.slug || city.id}-${index}`} className="h-full min-h-[140px]">
<CityCard
city={city}
rank={i + 1}
rank={index + 1}
onClick={() => setCityModal(city)}
/>
</div>
))}
</div>
{filteredCities.length === 0 && (
<p className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("noMatch")}
</p>
{displayCities.length === 0 && (
<p className="text-center py-8 text-gray-500 dark:text-gray-400">{t("noMatch")}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-sm">
{t("costCurve")} {t("devSuffix")}
</span>
</div>
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-sm">
{t("nomadHeatmap")} {t("devSuffix")}
</span>
</div>
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 min-h-40">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("costCurve")}
</h3>
<div className="space-y-3">
{displayCities.slice(0, 6).map((city) => (
<div key={`cost-${city.slug || city.id}`}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{city.name}</span>
<span>¥{city.costPerMonth.toLocaleString()}/</span>
</div>
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-[#ff4d4f]"
style={{ width: `${Math.max(10, (city.costPerMonth / topCost) * 100)}%` }}
/>
</div>
</div>
))}
</div>
</section>
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 min-h-40">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("nomadHeatmap")}
</h3>
<div className="space-y-3">
{displayCities.slice(0, 6).map((city) => (
<div key={`community-${city.slug || city.id}`}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{city.icon} {city.name}</span>
<span>{city.nomadsNow.toLocaleString()} </span>
</div>
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${Math.max(10, (city.nomadsNow / topNomads) * 100)}%` }}
/>
</div>
</div>
))}
</div>
</section>
</div>
<div className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
{routes.length > 0 && (
<section className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-medium text-gray-900 dark:text-gray-100">线</h3>
<span className="text-xs text-gray-400 dark:text-gray-500">{routes.length} 线</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{routes.slice(0, 3).map((route) => (
<Link
key={route.slug}
href={`/dashboard?route=${encodeURIComponent(route.slug)}`}
className="rounded-xl border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 p-3 hover:border-[#ff4d4f]/40 transition-colors"
>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100 line-clamp-1">
{route.title}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{route.durationDays} · ¥{route.budget.toLocaleString()}
</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{route.stops?.join(" → ") || route.description}
</p>
</Link>
))}
</div>
</section>
)}
<section className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("nearbyEvents")}
</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{[
{ city: "大理", date: "3月9日", emoji: "🏯" },
{ city: "成都", date: "3月12日", emoji: "🐼" },
{ city: "深圳", date: "3月14日", emoji: "🌃" },
].map((e) => (
{(upcomingMeetups.length ? upcomingMeetups : meetups).slice(0, 6).map((meetup) => (
<Link
key={e.city}
key={meetup.id}
href="/meetups"
className="shrink-0 px-4 py-2 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{e.emoji} {e.city} {e.date}
{meetup.emoji || "📍"} {meetup.city} {formatMeetupDate(meetup.date, meetup.time)}
</Link>
))}
</div>
</div>
</section>
</main>
</div>
</div>

View File

@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
@@ -29,7 +30,17 @@ interface MatchCard {
photo: string;
}
const MOCK_PROFILES: Profile[] = [
interface ProfileRecord {
id: string;
name?: string;
age?: number;
location?: string;
tags?: string[];
bio?: string;
photo?: string;
}
const FALLBACK_PROFILES: Profile[] = [
{ id: "1", name: "林晓雨", age: 28, location: "大理", tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", gradient: "from-rose-400 via-pink-400 to-fuchsia-400", initial: "林", avatarColor: "bg-rose-500", photo: "https://i.pravatar.cc/150?img=1" },
{ id: "2", name: "陈浩然", age: 32, location: "成都", tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", gradient: "from-amber-400 via-orange-400 to-red-400", initial: "陈", avatarColor: "bg-amber-500", photo: "https://i.pravatar.cc/150?img=3" },
{ id: "3", name: "王思琪", age: 26, location: "深圳", tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", gradient: "from-emerald-400 via-teal-400 to-cyan-400", initial: "王", avatarColor: "bg-emerald-500", photo: "https://i.pravatar.cc/150?img=5" },
@@ -38,7 +49,7 @@ const MOCK_PROFILES: Profile[] = [
{ id: "6", name: "刘子轩", age: 33, location: "昆明", tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", gradient: "from-blue-400 via-indigo-400 to-violet-500", initial: "刘", avatarColor: "bg-blue-500", photo: "https://i.pravatar.cc/150?img=11" },
];
const MOCK_MATCHES: MatchCard[] = [
const FALLBACK_MATCHES: MatchCard[] = [
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: "https://i.pravatar.cc/150?img=29" },
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: "https://i.pravatar.cc/150?img=34" },
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: "https://i.pravatar.cc/150?img=36" },
@@ -48,20 +59,123 @@ const MOCK_MATCHES: MatchCard[] = [
const TAB_ORDER: TabType[] = ["friends", "dating", "partner", "roommate", "cofounder", "explore"];
const PROFILE_GRADIENTS = [
"from-rose-400 via-pink-400 to-fuchsia-400",
"from-amber-400 via-orange-400 to-red-400",
"from-emerald-400 via-teal-400 to-cyan-400",
"from-violet-400 via-purple-400 to-indigo-400",
"from-blue-400 via-indigo-400 to-violet-500",
];
function mapProfile(record: ProfileRecord, index: number): Profile {
const name = record.name || "数字游民";
const initial = name.slice(0, 1);
return {
id: record.id,
name,
age: Number(record.age || 28 + index),
location: record.location || "远程",
tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"],
bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。",
gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length],
initial,
avatarColor: "bg-[#ff4d4f]",
photo: record.photo || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`,
};
}
function toMatch(profile: Profile, index: number): MatchCard {
return {
id: `m-${profile.id}`,
name: profile.name,
location: profile.location,
timeAgo: index < 2 ? "刚刚活跃" : "本周活跃",
initial: profile.initial,
color: profile.avatarColor,
photo: profile.photo,
};
}
export default function DatingPage() {
const { t } = useTranslation("match");
const [activeTab, setActiveTab] = useState<TabType>("friends");
const [currentIndex, setCurrentIndex] = useState(0);
const [profiles, setProfiles] = useState<Profile[]>(FALLBACK_PROFILES);
const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null);
const startXRef = useRef(0);
const currentProfile = MOCK_PROFILES[currentIndex];
const hasMoreProfiles = currentIndex < MOCK_PROFILES.length - 1;
useEffect(() => {
apiFetch<{ items: ProfileRecord[] }>("/api/profiles")
.then((data) => {
if (data.items?.length) {
setProfiles(data.items.map(mapProfile));
setCurrentIndex(0);
}
})
.catch(() => setProfiles(FALLBACK_PROFILES));
}, []);
const matches = useMemo(
() => (profiles.length ? profiles.slice(0, 5).map(toMatch) : FALLBACK_MATCHES),
[profiles]
);
const currentProfile = profiles[currentIndex] || profiles[0] || FALLBACK_PROFILES[0];
const hasMoreProfiles = currentIndex < profiles.length - 1;
const saveSwipe = (action: "like" | "dislike") => {
if (!currentProfile) return;
apiFetch("/api/matches/swipes", {
method: "POST",
body: JSON.stringify({ profileId: currentProfile.id, action }),
}).catch(() => {});
};
const nextProfile = () => {
window.setTimeout(() => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
setDragX(0);
setIsAnimatingOut(null);
}, 160);
};
const handleLike = () => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
saveSwipe("like");
setIsAnimatingOut("right");
setDragX(260);
nextProfile();
};
const handleDislike = () => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
saveSwipe("dislike");
setIsAnimatingOut("left");
setDragX(-260);
nextProfile();
};
const beginDrag = (clientX: number) => {
startXRef.current = clientX;
setIsDragging(true);
};
const moveDrag = (clientX: number) => {
if (!isDragging) return;
setDragX(Math.max(-180, Math.min(180, clientX - startXRef.current)));
};
const endDrag = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragX > 90) {
handleLike();
return;
}
if (dragX < -90) {
handleDislike();
return;
}
setDragX(0);
};
const badgeMap: Record<TabType, string> = {
@@ -100,7 +214,7 @@ export default function DatingPage() {
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t("title")} 💕</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("likedYou")}</p>
<div className="space-y-3">
{MOCK_MATCHES.map((match) => (
{matches.map((match) => (
<div key={match.id} className="flex items-center gap-3 p-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
<img src={match.photo} alt={match.name} className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0" />
<div className="flex-1 min-w-0">
@@ -116,7 +230,32 @@ export default function DatingPage() {
<main className="flex-1 min-w-0 flex flex-col items-center">
<div className="w-full max-w-md mx-auto">
<div className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20`}>
<div
className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20 touch-pan-y select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"} transition-transform`}
style={{
transform: `translateX(${isAnimatingOut === "right" ? 420 : isAnimatingOut === "left" ? -420 : dragX}px) rotate(${dragX / 18}deg)`,
opacity: isAnimatingOut ? 0.25 : 1,
transitionDuration: isDragging ? "0ms" : "180ms",
}}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight") handleLike();
if (e.key === "ArrowLeft") handleDislike();
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
}}
onPointerMove={(e) => moveDrag(e.clientX)}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className={`pointer-events-none absolute left-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX > 45 ? "opacity-100" : "opacity-0"}`}>
</div>
<div className={`pointer-events-none absolute right-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX < -45 ? "opacity-100" : "opacity-0"}`}>
</div>
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">{badgeText}</span>
<a href="#" className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium">{t("report")}</a>
@@ -152,7 +291,7 @@ export default function DatingPage() {
</div>
</div>
</div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {MOCK_PROFILES.length}</p>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {profiles.length}</p>
</div>
</main>
<div className="hidden lg:block w-72 flex-shrink-0" />

View File

@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useTranslation, Link } from "@/i18n/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface Topic {
id: string;
@@ -15,9 +16,22 @@ interface Topic {
category: string;
lastActive: string;
pinned: boolean;
excerpt?: string;
}
const MOCK_TOPICS: Topic[] = [
interface DiscussionRecord {
id: string;
title: string;
author?: string;
city?: string;
tags?: string[];
excerpt?: string;
replies?: number;
views?: number;
created?: string;
}
const FALLBACK_TOPICS: Topic[] = [
{
id: "1",
title: "大理最佳共享办公空间推荐 Top 10",
@@ -127,16 +141,71 @@ const CATEGORIES = [
{ key: "目的地", zh: "目的地", en: "Destinations" },
];
function mapDiscussion(topic: DiscussionRecord, index: number): Topic {
const author = topic.author || "NomadCNA";
const tags = Array.isArray(topic.tags) ? topic.tags : [];
return {
id: topic.id,
title: topic.title,
author,
avatar: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`,
views: Number(topic.views || 0),
replies: Number(topic.replies || 0),
likes: Math.max(3, Math.round(Number(topic.views || 0) / 30) + Number(topic.replies || 0)),
category: tags[0] || topic.city || "资源分享",
lastActive: topic.created ? new Date(topic.created).toLocaleDateString("zh-CN") : "刚刚",
pinned: index < 2,
excerpt: topic.excerpt,
};
}
export default function DiscussPage() {
const { t } = useTranslation("discuss");
const [activeCategory, setActiveCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS);
const [showComposer, setShowComposer] = useState(false);
const [posting, setPosting] = useState(false);
const [draft, setDraft] = useState({ title: "", city: "", excerpt: "" });
const filteredTopics = MOCK_TOPICS.filter((topic) => {
useEffect(() => {
apiFetch<{ items: DiscussionRecord[] }>("/api/discussions")
.then((data) => {
if (data.items?.length) {
setTopics(data.items.map(mapDiscussion));
}
})
.catch(() => setTopics(FALLBACK_TOPICS));
}, []);
const filteredTopics = useMemo(() => topics.filter((topic) => {
const matchesCategory = activeCategory === "all" || topic.category === activeCategory;
const matchesSearch = topic.title.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}), [activeCategory, searchQuery, topics]);
const createTopic = async (e: React.FormEvent) => {
e.preventDefault();
if (!draft.title.trim()) return;
setPosting(true);
try {
const data = await apiFetch<{ record: DiscussionRecord }>("/api/discussions", {
method: "POST",
body: JSON.stringify({
title: draft.title.trim(),
author: "本地用户",
city: draft.city.trim(),
tags: [draft.city.trim() || "资源分享"],
excerpt: draft.excerpt.trim(),
}),
});
setTopics((current) => [mapDiscussion(data.record, 0), ...current]);
setDraft({ title: "", city: "", excerpt: "" });
setShowComposer(false);
} finally {
setPosting(false);
}
};
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
@@ -150,11 +219,49 @@ export default function DiscussPage() {
{t("subtitle")}
</p>
</div>
<button className="bg-[#ff4d4f] text-white px-4 sm:px-6 py-2 sm:py-2.5 rounded-full font-medium text-sm hover:bg-[#ff7a45] transition-colors">
<button
onClick={() => setShowComposer((value) => !value)}
className="bg-[#ff4d4f] text-white px-4 sm:px-6 py-2 sm:py-2.5 rounded-full font-medium text-sm hover:bg-[#ff7a45] transition-colors"
>
{t("newPost")}
</button>
</div>
{showComposer && (
<form onSubmit={createTopic} className="mb-6 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="grid gap-3 sm:grid-cols-[1fr_180px]">
<input
required
value={draft.title}
onChange={(e) => setDraft((v) => ({ ...v, title: e.target.value }))}
placeholder="帖子标题"
className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<input
value={draft.city}
onChange={(e) => setDraft((v) => ({ ...v, city: e.target.value }))}
placeholder="关联城市"
className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<textarea
value={draft.excerpt}
onChange={(e) => setDraft((v) => ({ ...v, excerpt: e.target.value }))}
placeholder="写一点背景,后续会关联到城市弹窗的同城讨论"
rows={3}
className="mt-3 w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<div className="mt-3 flex justify-end gap-2">
<button type="button" onClick={() => setShowComposer(false)} className="rounded-full border border-gray-200 px-4 py-2 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
</button>
<button disabled={posting} className="rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{posting ? "发布中..." : "发布"}
</button>
</div>
</form>
)}
<div className="mb-6">
<input
type="text"
@@ -205,6 +312,9 @@ export default function DiscussPage() {
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-sm sm:text-base mb-2 line-clamp-2">
{topic.title}
</h3>
{topic.excerpt && (
<p className="mb-2 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{topic.excerpt}</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{topic.author}</span>
<span className="hidden sm:inline">·</span>

View File

@@ -0,0 +1,109 @@
"use client";
import { use, useEffect, useState } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import Footer from "@/app/components/Footer";
interface ContentItem {
slug: string;
title: string;
titleEn?: string;
subtitle: string;
subtitleEn?: string;
description: string;
descriptionEn?: string;
coverImage: string;
mediaUrl?: string;
chapters?: { title: string; minutes?: number }[];
durationMinutes?: number;
}
interface CityLite {
slug: string;
name: string;
nameEn?: string;
icon: string;
costPerMonth: number;
internetSpeed: number;
}
export default function EbookPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const locale = useLocale();
const [item, setItem] = useState<ContentItem | null>(null);
const [cities, setCities] = useState<CityLite[]>([]);
useEffect(() => {
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
.then((data) => {
setItem(data.item);
setCities(data.cities || []);
})
.catch(() => setItem(null));
}, [slug]);
if (!item) {
return <div className="min-h-screen bg-[#fafafa] dark:bg-gray-950 p-10 text-gray-500">...</div>;
}
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const description = locale === "zh" ? item.description : item.descriptionEn || item.description;
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
<div className="grid gap-8 lg:grid-cols-[360px_1fr]">
<div className="overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-gray-900">
<img src={item.coverImage} alt={title} className="h-96 w-full object-cover" />
</div>
<section>
<p className="text-sm font-semibold text-[#ff4d4f]"></p>
<h1 className="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl">{title}</h1>
<p className="mt-3 text-lg text-gray-500 dark:text-gray-400">{subtitle}</p>
<p className="mt-6 leading-8 text-gray-700 dark:text-gray-300">{description}</p>
<div className="mt-8 rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
<h2 className="font-bold text-gray-900 dark:text-gray-100"></h2>
<div className="mt-4 space-y-3">
{(item.chapters || []).map((chapter, index) => (
<div key={chapter.title} className="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-300">{index + 1}. {chapter.title}</span>
<span className="text-xs text-gray-400">{chapter.minutes || 8} min</span>
</div>
))}
</div>
</div>
<div className="mt-8 flex flex-wrap gap-3">
<a href={item.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
</a>
<Link href="/dashboard" className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
线
</Link>
<Link href="/" className="rounded-full border border-gray-300 px-5 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">
</Link>
</div>
</section>
</div>
<section className="mt-12">
<h2 className="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100"></h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cities.map((city) => (
<Link key={city.slug} href={`/city/${city.slug}`} className="rounded-2xl bg-white p-5 shadow-sm dark:bg-gray-900">
<div className="text-3xl">{city.icon}</div>
<h3 className="mt-3 font-bold text-gray-900 dark:text-gray-100">{locale === "zh" ? city.name : city.nameEn || city.name}</h3>
<p className="mt-1 text-sm text-gray-500">¥{city.costPerMonth}/ · {city.internetSpeed}Mbps</p>
</Link>
))}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
export default function FeedbackPage() {
const { t } = useTranslation("feedback");
@@ -14,8 +15,12 @@ export default function FeedbackPage() {
content: "",
});
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await apiFetch("/api/feedback", {
method: "POST",
body: JSON.stringify(form),
});
setSubmitted(true);
};
@@ -135,7 +140,7 @@ export default function FeedbackPage() {
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
{t("demoHint")}
FastAPI + PocketBase feedback
</p>
<button

View File

@@ -1,22 +1,134 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
const MOCK_GIGS = [
{ id: "1", title: "帮我 P 图", city: "大理", reward: "¥50", author: "林**" },
{ id: "2", title: "找惠州 loft 民宿", city: "深圳", reward: "¥100", author: "陈**" },
{ id: "3", title: "代收快递", city: "成都", reward: "¥30", author: "王**" },
{ id: "4", title: "一起 citywalk 拍 neo2 航拍", city: "杭州", reward: "面议", author: "张**" },
{ id: "5", title: "翻译英文文档", city: "上海", reward: "¥200", author: "李**" },
];
interface Gig {
id: string;
title: string;
category: string;
budget: number;
location: string;
description: string;
status: "open" | "in_progress" | "done" | string;
}
const INITIAL_FORM = {
title: "",
category: "内容",
budget: 500,
location: "远程",
description: "",
};
const STATUS_LABELS: Record<string, string> = {
open: "可接单",
in_progress: "进行中",
done: "已完成",
};
export default function GigsPage() {
const { t } = useTranslation("gigs");
const [gigs, setGigs] = useState<Gig[]>([]);
const [form, setForm] = useState(INITIAL_FORM);
const [showForm, setShowForm] = useState(false);
const [status, setStatus] = useState("");
const [submitting, setSubmitting] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const openGigs = useMemo(() => gigs.filter((gig) => gig.status === "open"), [gigs]);
const activeGigs = useMemo(() => gigs.filter((gig) => gig.status !== "open"), [gigs]);
const loadGigs = () => {
apiFetch<{ items: Gig[] }>("/api/gigs")
.then((data) => setGigs(data.items || []))
.catch(() => setGigs([]));
};
useEffect(() => {
loadGigs();
}, []);
const submitGig = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.title.trim()) {
setStatus("请先填写任务标题");
return;
}
setSubmitting(true);
setStatus("");
try {
const data = await apiFetch<{ record: Gig }>("/api/gigs", {
method: "POST",
body: JSON.stringify(form),
});
setGigs((prev) => [data.record, ...prev]);
setForm(INITIAL_FORM);
setShowForm(false);
setStatus("任务已发布,已写入 PocketBase。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "发布失败");
} finally {
setSubmitting(false);
}
};
const applyGig = async (gig: Gig) => {
setApplyingId(gig.id);
setStatus("");
try {
const data = await apiFetch<{ record: Gig; message: string }>(`/api/gigs/${gig.id}/apply`, {
method: "POST",
});
setGigs((prev) => prev.map((item) => (item.id === gig.id ? data.record : item)));
setStatus(`${gig.title}${data.message}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "接单失败");
} finally {
setApplyingId(null);
}
};
const renderGig = (gig: Gig) => (
<article
key={gig.id}
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
{gig.category}
</span>
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
{STATUS_LABELS[gig.status] || gig.status}
</span>
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{gig.title}</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
📍 {gig.location} · ¥{Number(gig.budget || 0).toLocaleString()}
</p>
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
{gig.description || "需求方暂未填写详细说明,接单后可继续沟通。"}
</p>
</div>
<button
type="button"
onClick={() => applyGig(gig)}
disabled={gig.status !== "open" || applyingId === gig.id}
className="shrink-0 rounded-xl bg-[#ff4d4f] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#ff3333] disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 dark:disabled:bg-gray-700"
>
{gig.status === "open" ? (applyingId === gig.id ? "提交中" : "接单") : "已锁定"}
</button>
</div>
</article>
);
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-12">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
@@ -26,36 +138,113 @@ export default function GigsPage() {
{t("subtitle")} · {t("platformFee")}
</p>
</div>
<button className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors">
{t("publish")}
<button
type="button"
onClick={() => setShowForm((prev) => !prev)}
className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors"
>
{showForm ? "收起表单" : t("publish")}
</button>
</div>
<div className="space-y-4">
{MOCK_GIGS.map((gig) => (
<div
key={gig.id}
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{gig.title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
📍 {gig.city} · {gig.author}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-lg font-semibold text-[#ff4d4f]">
{gig.reward}
</span>
<button className="px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">
{t("browse")}
</button>
</div>
{showForm && (
<form
onSubmit={submitGig}
className="mb-8 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6"
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2 block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
placeholder="例如:补充大理共享办公空间清单"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<select
value={form.category}
onChange={(event) => setForm({ ...form, category: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
>
{["内容", "调研", "摄影", "活动", "设计", "开发"].map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
type="number"
min={1}
value={form.budget}
onChange={(event) => setForm({ ...form, budget: Number(event.target.value) || 1 })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
value={form.location}
onChange={(event) => setForm({ ...form, location: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
/>
</label>
<label className="sm:col-span-2 block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<textarea
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
rows={4}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
placeholder="交付物、时间、资料来源、验收标准"
/>
</label>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-xl bg-gray-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900"
>
{submitting ? "发布中" : "写入数据库并发布"}
</button>
</div>
</form>
)}
{status && (
<div className="mb-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-200">
{status}
</div>
)}
<section className="mb-10">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
<span className="text-sm text-gray-400 dark:text-gray-500">{openGigs.length} </span>
</div>
<div className="space-y-4">
{openGigs.map(renderGig)}
{openGigs.length === 0 && (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
</div>
)}
</div>
</section>
{activeGigs.length > 0 && (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
<span className="text-sm text-gray-400 dark:text-gray-500">{activeGigs.length} </span>
</div>
<div className="space-y-4">{activeGigs.map(renderGig)}</div>
</section>
)}
</div>
<Footer />
</div>

View File

@@ -10,6 +10,7 @@ import {
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";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
@@ -67,13 +68,13 @@ export default function JoinPage() {
return;
}
const checkExisting = async () => {
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setCheckingStatus(false);
return;
}
const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" });
const statusRes = await fetch(apiUrl("/api/join/status"), { credentials: "include", cache: "no-store" });
const statusData = await statusRes.json();
if (statusData?.hasRecord && statusData?.isComplete) {
setIsAlreadyRecorded(true);
@@ -105,7 +106,7 @@ export default function JoinPage() {
setError(null);
setSubmitting(true);
try {
const res = await fetch("/api/join", {
const res = await fetch(apiUrl("/api/join"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, media: media?.url }),
@@ -121,7 +122,7 @@ export default function JoinPage() {
return;
}
// 再次确认 VIP防止 handleAuthSuccess 等路径绕过
const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipRes2 = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" });
const vipData2 = await vipRes2.json();
if (vipData2?.vip) {
setIsVipSubmit(true);
@@ -152,14 +153,14 @@ export default function JoinPage() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setPendingSubmit(true);
setAuthOpen(true);
return;
}
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipRes = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
if (vipData?.vip) {
setIsVipSubmit(true);
@@ -401,7 +402,7 @@ export default function JoinPage() {
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload-media", {
const res = await fetch(apiUrl("/api/upload-media"), {
method: "POST",
body: fd,
});

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
import { apiUrl } from "@/app/lib/api-client";
const DEFAULT_PASSWORD = "12345678";
@@ -33,7 +34,7 @@ export default function JoinPaidPage() {
let cancelled = false;
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
const r = await fetch("/api/meetup/complete-order", {
const r = await fetch(apiUrl("/api/meetup/complete-order"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
@@ -43,7 +44,7 @@ export default function JoinPaidPage() {
if (!d?.ok) return { ok: false, error: d?.error || `HTTP ${r.status}` };
if (d?.token && d?.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: d.token, record: d.record }),
@@ -59,13 +60,13 @@ export default function JoinPaidPage() {
window.dispatchEvent(new CustomEvent("vip:updated"));
}, 400);
if (d.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" });
if (!cancelled) {
setNeedPasswordChange(true);
setShowPasswordModal(true);
}
} else {
const needRes = await fetch("/api/meetup/need-password-change", { credentials: "include" });
const needRes = await fetch(apiUrl("/api/meetup/need-password-change"), { credentials: "include" });
const needData = await needRes.json().catch(() => ({}));
if (!cancelled && needData?.need === true) {
setNeedPasswordChange(true);
@@ -121,7 +122,7 @@ export default function JoinPaidPage() {
setErrorDetail(lastError);
}
fetch("/api/meetup/need-password-change")
fetch(apiUrl("/api/meetup/need-password-change"))
.then((res) => res.json())
.then((d) => {
if (cancelled) return;
@@ -166,7 +167,7 @@ export default function JoinPaidPage() {
}
setChanging(true);
try {
const res = await fetch("/api/auth/change-password", {
const res = await fetch(apiUrl("/api/auth/change-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword, passwordConfirm }),

View File

@@ -1,25 +1,33 @@
"use client";
import { useState, useMemo } from "react";
import { useEffect, useState, useMemo } from "react";
import { useLocale, useTranslation } from "@/i18n/navigation";
import { useTheme } from "@/app/context/ThemeContext";
import { MembersMap } from "@/app/lib/charts";
import { MAP_CITIES } from "@/app/data/map-cities";
import { apiFetch } from "@/app/lib/api-client";
export default function MembersMapPage() {
const { t } = useTranslation("membersMap");
const locale = useLocale();
const { theme } = useTheme();
const [mapCities, setMapCities] = useState(MAP_CITIES);
const [hoveredCity, setHoveredCity] = useState<typeof MAP_CITIES[0] | null>(null);
useEffect(() => {
apiFetch<{ items: typeof MAP_CITIES }>("/api/stats/member-map")
.then((data) => data.items?.length && setMapCities(data.items))
.catch(() => setMapCities(MAP_CITIES));
}, []);
const totalMembers = useMemo(
() => MAP_CITIES.reduce((sum, c) => sum + c.nomadsNow, 0),
[]
() => mapCities.reduce((sum, c) => sum + c.nomadsNow, 0),
[mapCities]
);
const sortedCities = useMemo(
() => [...MAP_CITIES].sort((a, b) => b.nomadsNow - a.nomadsNow),
[]
() => [...mapCities].sort((a, b) => b.nomadsNow - a.nomadsNow),
[mapCities]
);
return (
@@ -39,7 +47,7 @@ export default function MembersMapPage() {
<div className="lg:col-span-2 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 overflow-hidden p-4 sm:p-6">
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
<MembersMap
data={MAP_CITIES}
data={mapCities}
locale={locale}
dark={theme === "dark"}
className="min-h-[400px]"
@@ -101,7 +109,7 @@ export default function MembersMapPage() {
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-6 text-center">
{t("demoHint")}
PocketBase
</p>
</main>
</div>

View File

@@ -1,15 +1,17 @@
"use client";
import { useState } from "react";
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import { Link, useTranslation } from "@/i18n/navigation";
import { cities } from "@/app/data/cities";
import { apiFetch } from "@/app/lib/api-client";
const CITY_OPTIONS = cities.map((c) => ({ value: c.name, label: `${c.icon} ${c.name}` }));
const CITY_BY_NAME = new Map(cities.map((city) => [city.name, city]));
export default function HostMeetupPage() {
const { t } = useTranslation("hostMeetup");
const locale = useLocale();
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
city: "",
date: "",
@@ -20,9 +22,33 @@ export default function HostMeetupPage() {
maxAttendees: "20",
});
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
const city = CITY_BY_NAME.get(form.city);
setSubmitting(true);
try {
await apiFetch("/api/meetups", {
method: "POST",
body: JSON.stringify({
city: form.city,
emoji: city?.icon || "📍",
date: form.date,
time: form.time,
venue: form.venue,
address: form.address,
description: form.description,
rsvpCount: 1,
gradientFrom: city?.gradientFrom || "#ff4d4f",
gradientTo: city?.gradientTo || "#ff7a45",
attendees: [],
isUpcoming: true,
status: "pending",
}),
});
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
if (submitted) {
@@ -183,14 +209,15 @@ export default function HostMeetupPage() {
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
{t("demoHint")}
PocketBase meetups pending
</p>
<button
type="submit"
disabled={submitting}
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
>
{t("submit")}
{submitting ? "提交中..." : t("submit")}
</button>
</form>
</main>

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
interface Attendee {
name: string;
@@ -42,7 +43,10 @@ const allMeetups: Meetup[] = [
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function formatMeetupDate(dateStr: string, time: string): string {
const d = new Date(dateStr + "T" + time);
const normalizedDate = dateStr.includes("T") || dateStr.includes(" ")
? dateStr.slice(0, 10)
: dateStr;
const d = new Date(normalizedDate + "T" + time);
const weekday = weekdays[d.getDay()];
const year = d.getFullYear();
const month = d.getMonth() + 1;
@@ -51,6 +55,24 @@ function formatMeetupDate(dateStr: string, time: string): string {
return `${weekday}, ${year}${month}${day}日, ${timeFormatted}`;
}
function mapMeetup(item: Partial<Meetup> & { id: string; status?: string }): Meetup {
return {
id: item.id,
city: item.city || "线上",
emoji: item.emoji || "📍",
date: item.date || new Date().toISOString().slice(0, 10),
time: item.time || "19:00",
venue: item.venue || "待定地点",
address: item.address || "活动地址确认中",
description: item.description || "数字游民同城交流活动。",
rsvpCount: Number(item.rsvpCount || 0),
gradientFrom: item.gradientFrom || "#ff4d4f",
gradientTo: item.gradientTo || "#ff7a45",
attendees: Array.isArray(item.attendees) ? item.attendees : [],
isUpcoming: Boolean(item.isUpcoming ?? true),
};
}
function MeetupCard({ meetup }: { meetup: Meetup }) {
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
return (
@@ -83,8 +105,20 @@ function MeetupCard({ meetup }: { meetup: Meetup }) {
export default function MeetupsPage() {
const { t } = useTranslation("meetups");
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
const upcomingMeetups = useMemo(() => allMeetups.filter((m) => m.isUpcoming), []);
const previousMeetups = useMemo(() => allMeetups.filter((m) => !m.isUpcoming), []);
const [meetups, setMeetups] = useState<Meetup[]>(allMeetups);
useEffect(() => {
apiFetch<{ items: Array<Partial<Meetup> & { id: string; status?: string }> }>("/api/meetups")
.then((data) => {
if (data.items?.length) {
setMeetups(data.items.map(mapMeetup));
}
})
.catch(() => setMeetups(allMeetups));
}, []);
const upcomingMeetups = useMemo(() => meetups.filter((m) => m.isUpcoming), [meetups]);
const previousMeetups = useMemo(() => meetups.filter((m) => !m.isUpcoming), [meetups]);
const displayedMeetups = activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
const totalCount = activeTab === "upcoming" ? upcomingMeetups.length : previousMeetups.length;

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useEffect, useState, useMemo } from "react";
import { Link, useLocale, useRouter } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation";
import HeroSection from "../components/HeroSection";
@@ -10,25 +10,241 @@ import CityModal from "../components/CityModal";
import Footer from "@/app/components/Footer";
import { cities, type City } from "../data/cities";
import { HOME_CONFIG } from "@/config";
import { apiFetch } from "@/app/lib/api-client";
const { memberPhotos, travelingNow, meetups: homeMeetups, hotTopics, routes, stats, communityStats } = HOME_CONFIG;
const {
memberPhotos: fallbackMemberPhotos,
travelingNow: fallbackTravelingNow,
meetups: fallbackHomeMeetups,
hotTopics: fallbackHotTopics,
routes: fallbackRoutes,
stats: fallbackStats,
communityStats: fallbackCommunityStats,
} = HOME_CONFIG;
interface ContentItem {
slug: string;
type: "ebook" | "video" | "app" | "guide";
title: string;
titleEn?: string;
subtitle: string;
subtitleEn?: string;
description?: string;
coverImage: string;
targetUrl: string;
ctaLabel: string;
ctaLabelEn?: string;
durationMinutes?: number;
}
const FALLBACK_HOME_CONTENT: ContentItem[] = [
{
slug: "nomad-china-playbook",
type: "ebook",
title: "《中国数字游民落地手册》",
titleEn: "China Digital Nomad Playbook",
subtitle: "选城、预算、租房和社群融入的一本实操指南",
subtitleEn: "A practical guide for city choice, budget, housing, and community.",
coverImage: "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
targetUrl: "/ebooks/nomad-china-playbook",
ctaLabel: "查看电子书",
ctaLabelEn: "View ebook",
},
{
slug: "lin-dali-documentary",
type: "video",
title: "林晓雨:在大理远程工作的现实一天",
titleEn: "A remote work day in Dali",
subtitle: "个人访谈 / 现实纪录片",
subtitleEn: "Interview documentary",
coverImage: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
targetUrl: "/videos/lin-dali-documentary",
ctaLabel: "观看访谈",
ctaLabelEn: "Watch",
},
{
slug: "nomadcna-app",
type: "app",
title: "NomadCNA App",
titleEn: "NomadCNA App",
subtitle: "记录旅居路线、发现同城活动、保存城市清单",
subtitleEn: "Track routes, discover meetups, and save city lists.",
coverImage: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200",
targetUrl: "/app",
ctaLabel: "下载 App",
ctaLabelEn: "Download app",
},
{
slug: "digital-nomad-guide",
type: "guide",
title: "数字游民指南",
titleEn: "Digital Nomad Guide",
subtitle: "完整方法论、工具、案例和长期更新",
subtitleEn: "Methods, tools, cases, and long-term updates.",
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
targetUrl: "https://digital.hackrobot.cn/zh",
ctaLabel: "打开指南",
ctaLabelEn: "Open guide",
},
];
interface ProfileItem {
id: string;
name: string;
location?: string;
photo: string;
}
interface MeetupItem {
id: string;
city: string;
emoji?: string;
date?: string;
time?: string;
rsvpCount?: number;
venue?: string;
}
interface TopicItem {
id: string;
title: string;
titleEn?: string;
views: number;
}
interface RouteItem {
id?: string;
slug: string;
title: string;
titleEn?: string;
durationDays: number;
budget: number;
description: string;
descriptionEn?: string;
stops?: string[];
cities?: City[];
}
interface HomeStats {
cities: string;
nomads: string;
homeMeetups: string;
cost: string;
}
function safetyOrder(s: string): number {
const o: Record<string, number> = { 极好: 5, 很好: 4, : 3, 一般: 2, : 1 };
return o[s] || 0;
}
function shortDate(date?: string): string {
if (!date) return "时间待定";
const month = Number(date.slice(5, 7));
const day = Number(date.slice(8, 10));
return `${month}${day}`;
}
export default function Home() {
const [activeFilter, setActiveFilter] = useState("全部");
const [sortBy, setSortBy] = useState("score");
const [cityModal, setCityModal] = useState<City | null>(null);
const [liveCities, setLiveCities] = useState<City[]>(cities);
const [homeContent, setHomeContent] = useState<ContentItem[]>(FALLBACK_HOME_CONTENT);
const [homeStats, setHomeStats] = useState<HomeStats>(fallbackStats);
const [memberPhotos, setMemberPhotos] = useState(fallbackMemberPhotos);
const [travelingNow, setTravelingNow] = useState(fallbackTravelingNow);
const [homeMeetups, setHomeMeetups] = useState(fallbackHomeMeetups);
const [hotTopics, setHotTopics] = useState(fallbackHotTopics);
const [routes, setRoutes] = useState<RouteItem[]>([]);
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
const locale = useLocale();
const router = useRouter();
const { t } = useTranslation("home");
const { t: tNav } = useTranslation("nav");
useEffect(() => {
apiFetch<{ ok: boolean; items: City[] }>("/api/cities")
.then((data) => {
if (data.items?.length) {
setLiveCities(data.items.map((item) => ({ ...item, id: Number((item as unknown as { idNumber?: number }).idNumber || item.id) })));
}
})
.catch(() => setLiveCities(cities));
apiFetch<{ ok: boolean; items: ContentItem[] }>("/api/content/home")
.then((data) => setHomeContent(data.items?.length ? data.items : FALLBACK_HOME_CONTENT))
.catch(() => setHomeContent(FALLBACK_HOME_CONTENT));
apiFetch<{ stats: { cities: number; nomads: number; meetups: number; minCost: number; profiles: number; discussions: number } }>("/api/stats/overview")
.then((data) => {
setHomeStats({
cities: data.stats.cities.toLocaleString(),
nomads: data.stats.nomads.toLocaleString(),
homeMeetups: data.stats.meetups.toLocaleString(),
cost: `¥${data.stats.minCost.toLocaleString()}`,
});
setCommunityStats({
onlineMembers: `${data.stats.profiles.toLocaleString()}+`,
messagesPerMonth: `${Math.max(data.stats.discussions * 180, 1).toLocaleString()}+`,
});
})
.catch(() => {
setHomeStats(fallbackStats);
setCommunityStats(fallbackCommunityStats);
});
apiFetch<{ items: ProfileItem[] }>("/api/profiles")
.then((data) => {
const profiles = (data.items || []).slice(0, 12);
if (profiles.length) {
setMemberPhotos(profiles.map((profile) => ({ name: profile.name, photo: profile.photo })));
setTravelingNow(
profiles.map((profile) => ({
name: profile.name,
photo: profile.photo,
dest: profile.location || "远程",
}))
);
}
})
.catch(() => {
setMemberPhotos(fallbackMemberPhotos);
setTravelingNow(fallbackTravelingNow);
});
apiFetch<{ items: MeetupItem[] }>("/api/meetups")
.then((data) => {
const meetups = (data.items || []).slice(0, 6);
if (meetups.length) {
setHomeMeetups(
meetups.map((meetup) => ({
city: meetup.city,
emoji: meetup.emoji || "📍",
date: shortDate(meetup.date),
count: Number(meetup.rsvpCount || 0),
}))
);
}
})
.catch(() => setHomeMeetups(fallbackHomeMeetups));
apiFetch<{ items: TopicItem[] }>("/api/discussions")
.then((data) => {
const topics = [...(data.items || [])]
.sort((a, b) => Number(b.views || 0) - Number(a.views || 0))
.slice(0, 5);
if (topics.length) {
setHotTopics(
topics.map((topic) => ({
titleZh: topic.title,
titleEn: topic.titleEn || topic.title,
views: Number(topic.views || 0),
}))
);
}
})
.catch(() => setHotTopics(fallbackHotTopics));
apiFetch<{ items: RouteItem[] }>("/api/routes")
.then((data) => setRoutes(data.items || []))
.catch(() => setRoutes([]));
}, []);
const filteredCities = useMemo(() => {
let result = [...cities];
let result = [...liveCities];
if (activeFilter !== "全部") {
result = result.filter((c) => c.tags.includes(activeFilter));
}
@@ -51,7 +267,27 @@ export default function Home() {
}
});
return result;
}, [activeFilter, sortBy]);
}, [activeFilter, sortBy, liveCities]);
const displayRoutes = routes.length
? routes
: fallbackRoutes.map((route, index) => ({
id: `fallback-${index}`,
slug: route.titleEn.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
title: route.titleZh,
titleEn: route.titleEn,
durationDays: Number(route.durationZh.match(/\d+/)?.[0] || 30),
budget: Number(route.costZh.replace(/[^\d]/g, "")) || 3000,
description: route.descZh,
descriptionEn: route.descEn,
stops: route.citiesZh,
cities: route.citiesZh.map((name, i) => ({
id: i,
name,
nameEn: route.citiesEn[i],
icon: route.emojis[i],
} as City)),
}));
const cityTags = [
{ key: "dali", emoji: "🏯", zh: "大理", en: "Dali" },
@@ -67,22 +303,22 @@ export default function Home() {
<div className="border-y border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-4 flex flex-wrap items-center justify-center gap-6 sm:gap-12 text-center">
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.cities}</span>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cities}</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cities")}</p>
</div>
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.nomads}</span>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.nomads}</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.nomads")}</p>
</div>
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.homeMeetups}</span>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.homeMeetups}</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.meetups")}</p>
</div>
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.cost}</span>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cost}</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cost")}</p>
</div>
</div>
@@ -99,7 +335,59 @@ export default function Home() {
</div>
<div className="items-grid">
<Link href="/join" className="right-item block">
{homeContent.slice(0, 4).map((item, index) => {
const isExternal = item.targetUrl.startsWith("http");
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
const typeMeta: Record<string, { header: string; icon: string; href: string }> = {
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
app: { header: "App", icon: "📱", href: "/app" },
guide: { header: "数字游民指南", icon: "🧭", href: item.targetUrl },
};
const meta = typeMeta[item.type] || typeMeta.guide;
const content = (
<>
<div className="right-item-header">{meta.header}</div>
<div className="relative h-[calc(100%-38px)] sm:h-[calc(100%-42px)] overflow-hidden">
<img src={item.coverImage} alt={title} className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-3 sm:p-4 text-white">
<div className="mb-1 text-xl sm:text-2xl">{meta.icon}</div>
<h3 className="line-clamp-2 text-xs sm:text-sm font-bold leading-snug">{title}</h3>
<p className="mt-1 line-clamp-2 text-[10px] sm:text-xs text-white/80">{subtitle}</p>
<span className="mt-2 inline-flex rounded-full bg-white/90 px-2.5 py-1 text-[10px] sm:text-xs font-semibold text-gray-900">
{cta}
</span>
</div>
</div>
</>
);
return isExternal ? (
<a
key={item.slug}
href={meta.href}
target="_blank"
rel="noopener noreferrer"
className="right-item block"
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
>
{content}
</a>
) : (
<Link
key={item.slug}
href={meta.href}
className="right-item block"
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
>
{content}
</Link>
);
})}
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 5 }}>
<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>
@@ -115,7 +403,7 @@ export default function Home() {
</div>
</Link>
<Link href="/dating" className="right-item block">
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 6 }}>
<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) => (
@@ -133,18 +421,18 @@ export default function Home() {
</div>
</Link>
<Link href="/meetups" className="right-item block">
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 7 }}>
<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)]">
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
🗓 {t("meetupDate")}
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
📍 {t("meetupLocation")}
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
👥 {t("meetupPeople")}
</div>
{homeMeetups.slice(0, 3).map((meetup) => (
<div
key={`${meetup.city}-${meetup.date}`}
className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100"
>
<span>{meetup.emoji}</span>
<span className="line-clamp-1">{meetup.city} · {meetup.date} · {meetup.count}{t("people")}</span>
</div>
))}
<div className="flex -space-x-1 mt-auto">
{memberPhotos.slice(0, 5).map((m) => (
<img
@@ -189,41 +477,41 @@ export default function Home() {
{t("hotRoutes")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{routes.map((route) => (
{displayRoutes.map((route) => (
<Link
key={route.titleZh}
href={`/dashboard?route=${encodeURIComponent(locale === "zh" ? route.titleZh : route.titleEn)}`}
key={route.slug}
href={`/dashboard?route=${encodeURIComponent(route.slug)}`}
className="community-card group cursor-pointer block"
>
<div className={`h-2 bg-gradient-to-r ${route.gradient}`} />
<div className="h-2 bg-gradient-to-r from-[#ff4d4f] to-[#ff7a45]" />
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">
{locale === "zh" ? route.titleZh : route.titleEn}
{locale === "zh" ? route.title : route.titleEn || route.title}
</h3>
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
{locale === "zh" ? route.durationZh : route.durationEn}
{route.durationDays}
</span>
</div>
<div className="flex items-center gap-1 mb-3">
{route.emojis.map((emoji, i) => (
{(route.cities || []).slice(0, 4).map((city, i) => (
<span key={i} className="flex items-center gap-0.5 text-sm">
<span>{emoji}</span>
<span>{city.icon || "📍"}</span>
<span className="font-medium text-gray-700 dark:text-gray-300">
{locale === "zh" ? route.citiesZh[i] : route.citiesEn[i]}
{locale === "zh" ? city.name : city.nameEn || city.name}
</span>
{i < route.emojis.length - 1 && (
{i < (route.cities || []).slice(0, 4).length - 1 && (
<span className="text-gray-300 dark:text-gray-600 mx-1"></span>
)}
</span>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
{locale === "zh" ? route.descZh : route.descEn}
{locale === "zh" ? route.description : route.descriptionEn || route.description}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{locale === "zh" ? route.costZh : route.costEn}
¥{route.budget.toLocaleString()}
</span>
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
{t("routeDetails")}
@@ -285,7 +573,7 @@ export default function Home() {
</span>
</div>
<div className="flex flex-wrap gap-2">
{travelingNow.map((m) => (
{travelingNow.slice(0, 16).map((m) => (
<img
key={m.name}
src={m.photo}
@@ -304,7 +592,7 @@ export default function Home() {
🔥 {t("hotDiscussions")}
</h3>
<div className="space-y-3">
{hotTopics.map((t_item, i) => (
{hotTopics.slice(0, 5).map((t_item, i) => (
<div key={t_item.titleZh} className="flex items-start gap-2.5 group">
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300 dark:text-gray-600"}`}>
{i + 1}

View File

@@ -1,39 +1,71 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "@/app/context/ThemeContext";
import { useLocale, useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { MembersMap, BarChart, PieChart, LineChart } from "@/app/lib/charts";
import { MAP_CITIES } from "@/app/data/map-cities";
import { apiFetch } from "@/app/lib/api-client";
// 演示数据
const getCityBarData = (locale: "zh" | "en") =>
[...MAP_CITIES]
.sort((a, b) => b.nomadsNow - a.nomadsNow)
.slice(0, 10)
.map((c) => ({ name: locale === "zh" ? c.name : c.nameEn, value: c.nomadsNow }));
const REGION_PIE_DATA = [
{ name: "华东", value: 3200 },
{ name: "华南", value: 1800 },
{ name: "西南", value: 2100 },
{ name: "华北", value: 1200 },
{ name: "其他", value: 700 },
];
const GROWTH_LINE_DATA = {
xAxis: ["1月", "2月", "3月", "4月", "5月", "6月"],
series: [
{ name: "新增成员", data: [320, 480, 520, 610, 720, 850] },
{ name: "活跃城市", data: [12, 14, 15, 16, 17, 18] },
],
};
function regionOf(city: { lat: number; lng: number }): string {
if (city.lng >= 118 && city.lat >= 28) return "华东";
if (city.lng >= 110 && city.lat < 28) return "华南";
if (city.lng < 110 && city.lat < 32) return "西南";
if (city.lat >= 34) return "华北";
return "其他";
}
export default function DataReportPage() {
const { t } = useTranslation("dataReport");
const locale = useLocale();
const { theme } = useTheme();
const dark = theme === "dark";
const [mapCities, setMapCities] = useState(MAP_CITIES);
useEffect(() => {
apiFetch<{ items: typeof MAP_CITIES }>("/api/stats/member-map")
.then((data) => {
if (data.items?.length) setMapCities(data.items);
})
.catch(() => setMapCities(MAP_CITIES));
}, []);
const cityBarData = useMemo(
() =>
[...mapCities]
.sort((a, b) => b.nomadsNow - a.nomadsNow)
.slice(0, 10)
.map((c) => ({ name: locale === "zh" ? c.name : c.nameEn, value: c.nomadsNow })),
[locale, mapCities]
);
const regionPieData = useMemo(() => {
const totals = new Map<string, number>();
mapCities.forEach((city) => {
const region = regionOf(city);
totals.set(region, (totals.get(region) || 0) + city.nomadsNow);
});
return Array.from(totals.entries()).map(([name, value]) => ({ name, value }));
}, [mapCities]);
const growthLineData = useMemo(() => {
const totalNomads = mapCities.reduce((sum, city) => sum + city.nomadsNow, 0);
const totalCities = mapCities.length;
return {
xAxis: ["1月", "2月", "3月", "4月", "5月", "6月"],
series: [
{
name: "活跃成员",
data: [0.62, 0.68, 0.74, 0.82, 0.91, 1].map((ratio) => Math.round(totalNomads * ratio)),
},
{
name: "活跃城市",
data: [0.66, 0.72, 0.78, 0.86, 0.94, 1].map((ratio) => Math.max(1, Math.round(totalCities * ratio))),
},
],
};
}, [mapCities]);
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
@@ -64,7 +96,7 @@ export default function DataReportPage() {
</h2>
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
<MembersMap
data={MAP_CITIES}
data={mapCities}
locale={locale}
dark={dark}
className="min-h-[400px]"
@@ -76,7 +108,7 @@ export default function DataReportPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
<BarChart
data={getCityBarData(locale)}
data={cityBarData}
title={t("cityRanking")}
dark={dark}
/>
@@ -84,7 +116,7 @@ export default function DataReportPage() {
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
<PieChart
data={REGION_PIE_DATA}
data={regionPieData}
title={t("regionDist")}
dark={dark}
/>
@@ -93,7 +125,7 @@ export default function DataReportPage() {
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
<LineChart
data={GROWTH_LINE_DATA}
data={growthLineData}
title={t("growthTrend")}
dark={dark}
/>
@@ -101,7 +133,7 @@ export default function DataReportPage() {
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-8 text-center">
{t("demoHint")}
FastAPI PocketBase cities
</p>
</main>
</div>

View File

@@ -1,143 +1,77 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation, Link } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface Service {
id: string;
slug: string;
icon: string;
titleZh: string;
titleEn: string;
descZh: string;
descEn: string;
features: { zh: string; en: string }[];
priceZh: string;
priceEn: string;
color: string;
title: string;
titleEn?: string;
description: string;
descriptionEn?: string;
features: string[];
price: number;
priceLabel: string;
category: string;
gradientFrom: string;
gradientTo: string;
}
const SERVICES: Service[] = [
const FALLBACK_SERVICES: Service[] = [
{
id: "insurance",
id: "remote-health-insurance",
slug: "remote-health-insurance",
icon: "🛡️",
titleZh: "旅行保",
titleEn: "Travel Insurance",
descZh: "数字游民定制方案,覆盖全球",
descEn: "Global coverage for digital nomads",
features: [
{ zh: "覆盖中国 + 境外", en: "Coverage in China + overseas" },
{ zh: "远程工作专属条款", en: "Remote work specific terms" },
{ zh: "医疗直付服务", en: "Direct medical payment" },
{ zh: "24/7 中文客服", en: "24/7 Chinese support" },
],
priceZh: "¥15/天起",
priceEn: "From ¥15/day",
color: "emerald",
gradientFrom: "from-emerald-500",
gradientTo: "to-teal-500",
},
{
id: "chat",
icon: "💬",
titleZh: "社区聊天",
titleEn: "Community Chat",
descZh: "与全球数字游民实时交流",
descEn: "Connect with nomads worldwide in real-time",
features: [
{ zh: "3,200+ 在线成员", en: "3,200+ members online" },
{ zh: "城市群/兴趣群", en: "City & interest groups" },
{ zh: "即时信息对接", en: "Instant networking" },
{ zh: "经验分享互助", en: "Experience sharing" },
],
priceZh: "免费",
priceEn: "Free",
color: "violet",
gradientFrom: "from-violet-500",
gradientTo: "to-purple-500",
},
{
id: "tools",
icon: "🧰",
titleZh: "工具箱",
titleEn: "Tools",
descZh: "汇率、税务、签证一站式",
descEn: "Exchange, tax & visa in one place",
features: [
{ zh: "实时汇率计算", en: "Real-time exchange rates" },
{ zh: "税务估算器", en: "Tax calculator" },
{ zh: "签证倒计时", en: "Visa countdown" },
{ zh: "FIRE 计算器", en: "FIRE calculator" },
],
priceZh: "免费",
priceEn: "Free",
color: "blue",
gradientFrom: "from-blue-500",
gradientTo: "to-cyan-500",
},
{
id: "coworking",
icon: "🏢",
titleZh: "共享办公",
titleEn: "Coworking",
descZh: "全球优质办公空间推荐",
descEn: "Premium workspace recommendations",
features: [
{ zh: "全球 10,000+ 空间", en: "10,000+ spaces worldwide" },
{ zh: "会员专属折扣", en: "Member discounts" },
{ zh: "实地考察报告", en: "On-site reviews" },
{ zh: "预订直通车", en: "Direct booking" },
],
priceZh: "¥99/月起",
priceEn: "From ¥99/month",
color: "orange",
gradientFrom: "from-orange-500",
gradientTo: "to-amber-500",
},
{
id: "visa",
icon: "📋",
titleZh: "签证服务",
titleEn: "Visa Services",
descZh: "专业签证顾问全程护航",
descEn: "Professional visa consultants",
features: [
{ zh: "全球签证办理", en: "Global visa processing" },
{ zh: "材料翻译公证", en: "Translation & notarization" },
{ zh: "加急服务", en: "Expedited service" },
{ zh: "拒签退款保障", en: "Refund guarantee" },
],
priceZh: "咨询报价",
priceEn: "Contact for quote",
color: "rose",
gradientFrom: "from-rose-500",
gradientTo: "to-pink-500",
},
{
id: "mentor",
icon: "🎓",
titleZh: "导师计划",
titleEn: "Mentor Program",
descZh: "资深游民一对一指导",
descEn: "1-on-1 guidance from experienced nomads",
features: [
{ zh: "职业规划建议", en: "Career planning" },
{ zh: "创业经验分享", en: "Startup experience" },
{ zh: "远程工作技巧", en: "Remote work tips" },
{ zh: "人脉资源对接", en: "Network access" },
],
priceZh: "¥299/次",
priceEn: "¥299/session",
color: "indigo",
gradientFrom: "from-indigo-500",
gradientTo: "to-violet-500",
title: "远程工作者医疗与旅行保",
titleEn: "Remote Health & Travel Coverage",
description: "覆盖国内旅居、短期出境、设备丢失和紧急医疗咨询。",
features: ["国内外医疗协助", "设备丢失补偿", "7x24 中文支持", "城市风险提醒"],
price: 15,
priceLabel: "¥15/天起",
category: "保障",
gradientFrom: "#10b981",
gradientTo: "#0f766e",
},
];
export default function ServicesPage() {
const { t } = useTranslation("services");
const [services, setServices] = useState<Service[]>(FALLBACK_SERVICES);
const [activeSlug, setActiveSlug] = useState<string | null>(null);
const [message, setMessage] = useState("");
useEffect(() => {
apiFetch<{ items: Service[] }>("/api/services")
.then((data) => {
if (data.items?.length) {
setServices(data.items);
}
})
.catch(() => setServices(FALLBACK_SERVICES));
}, []);
const submitLead = async (service: Service) => {
setActiveSlug(service.slug);
setMessage("");
try {
await apiFetch("/api/services/leads", {
method: "POST",
body: JSON.stringify({
serviceSlug: service.slug,
note: `用户从服务页咨询:${service.title}`,
}),
});
setMessage(`${service.title} 已记录咨询线索,可在 PocketBase 查看。`);
} catch (error) {
setMessage(error instanceof Error ? error.message : "提交失败");
} finally {
setActiveSlug(null);
}
};
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
@@ -151,69 +85,91 @@ export default function ServicesPage() {
</p>
</div>
{message && (
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{message}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
{SERVICES.map((service) => (
<div
key={service.id}
{services.map((service) => (
<article
key={service.slug}
className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-sm border border-gray-100 dark:border-gray-800 hover:shadow-lg transition-shadow"
>
<div className={`h-2 bg-gradient-to-r ${service.gradientFrom} ${service.gradientTo}`} />
<div
className="h-2"
style={{ background: `linear-gradient(90deg, ${service.gradientFrom}, ${service.gradientTo})` }}
/>
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-3xl">{service.icon}</span>
<div>
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-lg">
{service.titleZh}
{service.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{service.titleEn}
{service.titleEn || service.category}
</p>
</div>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{service.descZh}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-3">
{service.description}
</p>
<ul className="space-y-2 mb-5">
{service.features.map((feature, i) => (
<li key={i} className="flex items-center gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{(service.features || []).slice(0, 5).map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400"
>
<span className="text-[#ff4d4f]"></span>
{feature.zh}
{feature}
</li>
))}
</ul>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-800">
<div className="flex items-center justify-between gap-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<span className="font-bold text-gray-900 dark:text-gray-100">
{service.priceZh}
{service.priceLabel || `¥${service.price}`}
</span>
<Link
href="/join"
className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors"
>
{t("learnMore")}
</Link>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitLead(service)}
disabled={activeSlug === service.slug}
className="rounded-lg bg-[#ff4d4f] px-3 py-2 text-xs font-medium text-white hover:bg-[#ff3333] disabled:cursor-not-allowed disabled:bg-gray-300"
>
{activeSlug === service.slug ? "提交中" : "咨询"}
</button>
<Link
href="/join"
className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors"
>
{t("learnMore")}
</Link>
</div>
</div>
</div>
</div>
</article>
))}
</div>
<div className="mt-12 sm:mt-16 bg-gradient-to-r from-[#ff4d4f] to-[#ff7a45] rounded-2xl p-6 sm:p-10 text-center">
<h2 className="text-xl sm:text-2xl font-bold text-white mb-3">
</h2>
<p className="text-white/80 mb-5 text-sm sm:text-base">
线
</p>
<Link
href="/contact"
className="inline-block bg-white text-[#ff4d4f] px-6 py-2.5 rounded-full font-medium hover:bg-gray-100 transition-colors"
>
{t("contactSupport")}
</Link>
</div>
</main>

View File

@@ -1,8 +1,18 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface ToolCity {
id: string;
name: string;
temperature: number;
humidity: number;
costPerMonth: number;
internetSpeed: number;
}
const RATES = [
{ from: "CNY", to: "USD", rate: 0.14 },
@@ -16,11 +26,30 @@ export default function ToolsPage() {
const [income, setIncome] = useState(100000);
const [cnyAmount, setCnyAmount] = useState(10000);
const [visaDate, setVisaDate] = useState("2025-06-15");
const [monthlySpend, setMonthlySpend] = useState(6000);
const [targetTemp, setTargetTemp] = useState(20);
const [cities, setCities] = useState<ToolCity[]>([]);
const [today] = useState(() => Date.now());
useEffect(() => {
apiFetch<{ items: ToolCity[] }>("/api/cities")
.then((data) => setCities(data.items || []))
.catch(() => setCities([]));
}, []);
const taxEstimate = Math.round(income * 0.15);
const cnyToUsd = (v: number) => (v * 0.14).toFixed(2);
const daysLeft = Math.ceil(
(new Date(visaDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
(new Date(visaDate).getTime() - today) / (1000 * 60 * 60 * 24)
);
const fireNumber = monthlySpend * 12 * 25;
const yearsToFire = Math.max(1, Math.ceil(fireNumber / Math.max(1, income - monthlySpend * 12)));
const climateMatches = useMemo(
() =>
[...cities]
.sort((a, b) => Math.abs(a.temperature - targetTemp) - Math.abs(b.temperature - targetTemp))
.slice(0, 5),
[cities, targetTemp]
);
return (
@@ -129,8 +158,23 @@ export default function ToolsPage() {
🌱 {t("fireCalculator")}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("fireDesc")}</p>
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 text-center text-sm text-gray-500 dark:text-gray-400">
{t("comingSoon")}
<div className="grid gap-4 sm:grid-cols-[1fr_220px]">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
(¥)
</label>
<input
type="number"
value={monthlySpend}
onChange={(e) => setMonthlySpend(Number(e.target.value) || 0)}
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
<p className="text-gray-500 dark:text-gray-400">FIRE </p>
<p className="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100">¥{fireNumber.toLocaleString()}</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400"> 4% {yearsToFire} </p>
</div>
</div>
</section>
@@ -139,8 +183,30 @@ export default function ToolsPage() {
🌤 {t("climateFinder")}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("climateDesc")}</p>
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 text-center text-sm text-gray-500 dark:text-gray-400">
{t("comingSoon")}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{targetTemp}°C
</label>
<input
type="range"
min={10}
max={30}
value={targetTemp}
onChange={(e) => setTargetTemp(Number(e.target.value))}
className="w-full accent-[#ff4d4f]"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{climateMatches.map((city) => (
<div key={city.id} className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
<p className="font-semibold text-gray-900 dark:text-gray-100">{city.name}</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{city.temperature}°C · 湿 {city.humidity}% · ¥{city.costPerMonth.toLocaleString()}/ · {city.internetSpeed}Mbps
</p>
</div>
))}
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,94 @@
"use client";
import { use, useEffect, useState } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import Footer from "@/app/components/Footer";
interface ContentItem {
slug: string;
title: string;
titleEn?: string;
subtitle: string;
subtitleEn?: string;
description: string;
descriptionEn?: string;
coverImage: string;
mediaUrl: string;
chapters?: { title: string; time?: string }[];
relatedProfiles?: string[];
durationMinutes?: number;
}
interface CityLite {
slug: string;
name: string;
nameEn?: string;
icon: string;
nomadsNow: number;
}
export default function VideoPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const locale = useLocale();
const [item, setItem] = useState<ContentItem | null>(null);
const [cities, setCities] = useState<CityLite[]>([]);
useEffect(() => {
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
.then((data) => {
setItem(data.item);
setCities(data.cities || []);
})
.catch(() => setItem(null));
}, [slug]);
if (!item) {
return <div className="min-h-screen bg-[#0f172a] p-10 text-gray-300">...</div>;
}
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const description = locale === "zh" ? item.description : item.descriptionEn || item.description;
return (
<div className="min-h-screen bg-[#0f172a] text-white">
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
<Link href="/" className="text-sm text-white/60 hover:text-white"> </Link>
<div className="mt-6 overflow-hidden rounded-2xl bg-black shadow-2xl">
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
</div>
<div className="mt-8 grid gap-8 lg:grid-cols-[1fr_320px]">
<section>
<p className="text-sm font-semibold text-[#ff7a45]">访 / </p>
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">{title}</h1>
<p className="mt-2 text-lg text-white/60">{subtitle}</p>
<p className="mt-6 leading-8 text-white/75">{description}</p>
</section>
<aside className="rounded-2xl bg-white/10 p-5 backdrop-blur">
<h2 className="font-bold"></h2>
<div className="mt-4 space-y-3">
{(item.chapters || []).map((chapter) => (
<div key={chapter.title} className="rounded-xl bg-white/10 px-4 py-3">
<p className="text-sm">{chapter.title}</p>
<p className="mt-1 text-xs text-white/50">{chapter.time || "00:00"}</p>
</div>
))}
</div>
</aside>
</div>
<section className="mt-10">
<h2 className="mb-4 text-xl font-bold"></h2>
<div className="flex flex-wrap gap-3">
{cities.map((city) => (
<Link key={city.slug} href={`/city/${city.slug}`} className="rounded-full bg-white/10 px-4 py-2 text-sm hover:bg-white/20">
{city.icon} {locale === "zh" ? city.name : city.nameEn || city.name} · {city.nomadsNow}
</Link>
))}
</div>
</section>
</main>
<Footer />
</div>
);
}

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);

View File

@@ -1,6 +1,9 @@
export interface City {
id: number;
idNumber?: number;
slug?: string;
name: string;
nameEn?: string;
country: string;
countryEmoji: string;
overallScore: number;

View File

@@ -83,6 +83,7 @@ html {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 180px;
grid-auto-flow: row;
gap: 8px;
padding: 8px;
}
@@ -98,7 +99,7 @@ html {
@media (min-width: 1024px) {
.items-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 1fr);
grid-auto-rows: 280px;
gap: 30px;
padding: 20px 40px;
@@ -107,12 +108,13 @@ html {
@media (min-width: 1440px) {
.items-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, 1fr);
gap: 35px;
}
}
/* Pin right-items to the LAST explicit column */
/* 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;
@@ -125,6 +127,22 @@ html {
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;
}
/* ==================== City Card ==================== */
.city-card {

29
app/lib/api-client.ts Normal file
View File

@@ -0,0 +1,29 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000";
export function apiUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`;
}
export async function apiFetch<T>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(apiUrl(path), {
credentials: "include",
...init,
headers:
init?.body instanceof FormData
? init.headers
: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.detail || data?.error || `HTTP ${res.status}`);
}
return data as T;
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import * as echarts from "echarts";
import type { MapCity } from "./types";
import { apiUrl } from "@/app/lib/api-client";
const CHINA_MAP_URL = "/api/geo/china";
@@ -30,7 +31,7 @@ export function MembersMap({
const init = async () => {
try {
const res = await fetch(CHINA_MAP_URL);
const res = await fetch(apiUrl(CHINA_MAP_URL));
const geoJson = await res.json();
echarts.registerMap("china", geoJson);
} catch {

View File

@@ -4,6 +4,7 @@
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
import type { PayChannel, PayEnv } from "./types";
import { apiUrl } from "@/app/lib/api-client";
export function redirectToPay(params: {
user_id: string;
@@ -21,7 +22,7 @@ export function redirectToPay(params: {
const payForm = document.createElement("form");
payForm.method = "POST";
payForm.action = "/api/pay";
payForm.action = apiUrl("/api/pay");
payForm.target = "_self";
payForm.style.display = "none";

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { apiUrl } from "@/app/lib/api-client";
const COOKIE_NAME = "join_pay_order";
const STORAGE_NAME = "join_pay_order";
@@ -121,7 +122,7 @@ export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
inFlight = true;
try {
const res = await fetch(
`/api/pay/status?order_id=${encodeURIComponent(orderId)}`,
apiUrl(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`),
{ cache: "no-store" }
);
const data = await res.json().catch(() => ({}));

View File

@@ -4,6 +4,7 @@
import { getPocketBaseConfig } from "@/config/services.config";
import { PB_STORAGE_KEYS } from "./constants";
import { apiUrl } from "@/app/lib/api-client";
export interface AuthUser {
id: string;
@@ -24,15 +25,15 @@ export async function pbLogin(
identity: string,
password: string
): Promise<AuthResult> {
const url = getPBUrl();
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
const res = await fetch(apiUrl("/api/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
credentials: "include",
body: JSON.stringify({ email: identity, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "登录失败");
throw new Error(err?.detail || err?.message || "登录失败");
}
const data = await res.json();
if (!data?.token) throw new Error("登录响应异常");
@@ -44,21 +45,18 @@ export async function pbRegister(
password: string,
passwordConfirm: string
): Promise<AuthResult> {
const url = getPBUrl();
const config = getPocketBaseConfig();
const res = await fetch(
`${url}/api/collections/${config.usersCollection}/records`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirm }),
}
);
const res = await fetch(apiUrl("/api/auth/register"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password, passwordConfirm }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "注册失败");
throw new Error(err?.detail || err?.message || "注册失败");
}
return pbLogin(email, password);
const data = await res.json();
return { token: data.token, record: data.record };
}
export function pbSaveAuth(token: string, record: AuthUser): void {
@@ -79,7 +77,7 @@ export async function pbLogout(): Promise<void> {
/* ignore */
}
try {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
await fetch(apiUrl("/api/auth/logout"), { method: "POST", credentials: "include" });
} catch {
/* ignore */
}