'x'
This commit is contained in:
153
app/[locale]/feedback/page.tsx
Normal file
153
app/[locale]/feedback/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const { t } = useTranslation("feedback");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
type: "suggestion" as "suggestion" | "bug",
|
||||
email: "",
|
||||
title: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[640px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{submitted ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-8 text-center">
|
||||
<span className="text-5xl mb-4 block">🙏</span>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("successTitle")}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("successDesc")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setForm({ type: "suggestion", email: "", title: "", content: "" });
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t("submitAnother")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6 sm:p-8 space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("type")} *
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border cursor-pointer transition-colors has-[:checked]:border-[#ff4d4f] has-[:checked]:bg-[#ff4d4f]/5">
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
value="suggestion"
|
||||
checked={form.type === "suggestion"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, type: e.target.value as "suggestion" }))
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span>💡</span>
|
||||
<span>{t("suggestion")}</span>
|
||||
</label>
|
||||
<label className="flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border cursor-pointer transition-colors has-[:checked]:border-[#ff4d4f] has-[:checked]:bg-[#ff4d4f]/5">
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
value="bug"
|
||||
checked={form.type === "bug"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, type: e.target.value as "bug" }))
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span>🐛</span>
|
||||
<span>{t("bug")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("email")} *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("titleLabel")} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("content")} *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
value={form.content}
|
||||
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("demoHint")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { LocaleProvider } from "../context/LocaleContext";
|
||||
import type { Locale } from "../context/LocaleContext";
|
||||
import { getThemeMessages } from "@/app/lib/theme-data";
|
||||
import Navbar from "../components/Navbar";
|
||||
import SetLangAttr from "../components/SetLangAttr";
|
||||
|
||||
const LOCALES: Locale[] = ["zh", "en"];
|
||||
|
||||
@@ -33,6 +34,7 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||
<SetLangAttr />
|
||||
<Navbar />
|
||||
{children}
|
||||
</LocaleProvider>
|
||||
|
||||
109
app/[locale]/map/page.tsx
Normal file
109
app/[locale]/map/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { 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";
|
||||
|
||||
export default function MembersMapPage() {
|
||||
const { t } = useTranslation("membersMap");
|
||||
const locale = useLocale();
|
||||
const { theme } = useTheme();
|
||||
const [hoveredCity, setHoveredCity] = useState<typeof MAP_CITIES[0] | null>(null);
|
||||
|
||||
const totalMembers = useMemo(
|
||||
() => MAP_CITIES.reduce((sum, c) => sum + c.nomadsNow, 0),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortedCities = useMemo(
|
||||
() => [...MAP_CITIES].sort((a, b) => b.nomadsNow - a.nomadsNow),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[1200px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<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}
|
||||
locale={locale}
|
||||
dark={theme === "dark"}
|
||||
className="min-h-[400px]"
|
||||
onCityHover={setHoveredCity}
|
||||
/>
|
||||
</div>
|
||||
{hoveredCity && (
|
||||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{locale === "zh" ? hoveredCity.name : hoveredCity.nameEn} {hoveredCity.emoji}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{hoveredCity.nomadsNow} {t("nomadsInCity")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6">
|
||||
<h3 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
|
||||
{t("totalMembers")}
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{totalMembers.toLocaleString()}+
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6">
|
||||
<h3 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{t("cities")}
|
||||
</h3>
|
||||
<ul className="space-y-2 max-h-[280px] overflow-y-auto">
|
||||
{sortedCities.map((city) => (
|
||||
<li
|
||||
key={city.id}
|
||||
className={`flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-colors ${
|
||||
hoveredCity?.id === city.id
|
||||
? "bg-[#ff4d4f]/10 text-[#ff4d4f]"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredCity(city)}
|
||||
onMouseLeave={() => setHoveredCity(null)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{city.emoji}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{locale === "zh" ? city.name : city.nameEn}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{city.nomadsNow}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-6 text-center">
|
||||
{t("demoHint")}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
app/[locale]/meetups/host/page.tsx
Normal file
199
app/[locale]/meetups/host/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { cities } from "@/app/data/cities";
|
||||
|
||||
const CITY_OPTIONS = cities.map((c) => ({ value: c.name, label: `${c.emoji} ${c.name}` }));
|
||||
|
||||
export default function HostMeetupPage() {
|
||||
const { t } = useTranslation("hostMeetup");
|
||||
const locale = useLocale();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
city: "",
|
||||
date: "",
|
||||
time: "19:00",
|
||||
venue: "",
|
||||
address: "",
|
||||
description: "",
|
||||
maxAttendees: "20",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[600px] mx-auto px-4 sm:px-6 py-12 sm:py-20">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-800 p-8 sm:p-12 text-center">
|
||||
<span className="text-6xl mb-6 block">🎉</span>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("successTitle")}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-8">
|
||||
{t("successDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href="/meetups/host"
|
||||
onClick={() => setSubmitted(false)}
|
||||
className="px-6 py-3 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t("createAnother")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/meetups"
|
||||
className="px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("viewMeetups")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[640px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/meetups"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-4"
|
||||
>
|
||||
← {t("backToMeetups")}
|
||||
</Link>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6 sm:p-8 space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("city")} *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={form.city}
|
||||
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">{t("selectCity")}</option>
|
||||
{CITY_OPTIONS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("date")} *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("time")} *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.time}
|
||||
onChange={(e) => setForm((f) => ({ ...f, time: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("venue")} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("venuePlaceholder")}
|
||||
value={form.venue}
|
||||
onChange={(e) => setForm((f) => ({ ...f, venue: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("address")} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("addressPlaceholder")}
|
||||
value={form.address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("description")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("maxAttendees")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={form.maxAttendees}
|
||||
onChange={(e) => setForm((f) => ({ ...f, maxAttendees: e.target.value }))}
|
||||
className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("demoHint")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
|
||||
interface Attendee {
|
||||
name: string;
|
||||
@@ -80,6 +81,7 @@ 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), []);
|
||||
@@ -89,22 +91,31 @@ export default function MeetupsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
线下聚会
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
与全球数字游民面对面交流,拓展人脉,分享经验
|
||||
</p>
|
||||
<header className="mb-8 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/meetups/host"
|
||||
className="shrink-0 inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#ff4d4f] text-white text-sm font-medium hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
<span>🎤</span>
|
||||
{t("hostMeetup")}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-1 p-1 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-100 dark:border-gray-800 mb-8 inline-flex">
|
||||
<button onClick={() => setActiveTab("upcoming")} className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === "upcoming" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
|
||||
即将举办
|
||||
{t("upcoming")}
|
||||
</button>
|
||||
<button onClick={() => setActiveTab("previous")} className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === "previous" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
|
||||
往期聚会
|
||||
{t("previous")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Home() {
|
||||
/>
|
||||
))}
|
||||
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 text-center">
|
||||
{locale === "zh" ? "本月 86 人加入" : "86 joined this month"}
|
||||
{t("joinedThisMonthShort")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -137,13 +137,13 @@ export default function Home() {
|
||||
<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">
|
||||
🗓 3月15日
|
||||
🗓 {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">
|
||||
👥 12{t("people")}
|
||||
👥 {t("meetupPeople")}
|
||||
</div>
|
||||
<div className="flex -space-x-1 mt-auto">
|
||||
{memberPhotos.slice(0, 5).map((m) => (
|
||||
|
||||
109
app/[locale]/report/data/page.tsx
Normal file
109
app/[locale]/report/data/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
// 演示数据
|
||||
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] },
|
||||
],
|
||||
};
|
||||
|
||||
export default function DataReportPage() {
|
||||
const { t } = useTranslation("dataReport");
|
||||
const locale = useLocale();
|
||||
const { theme } = useTheme();
|
||||
const dark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<header className="mb-8 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div>
|
||||
<Link
|
||||
href="/report"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2"
|
||||
>
|
||||
← {t("backToReport")}
|
||||
</Link>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 成员地图 */}
|
||||
<section className="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">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t("membersMap")}
|
||||
</h2>
|
||||
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
|
||||
<MembersMap
|
||||
data={MAP_CITIES}
|
||||
locale={locale}
|
||||
dark={dark}
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 图表网格 */}
|
||||
<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)}
|
||||
title={t("cityRanking")}
|
||||
dark={dark}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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}
|
||||
title={t("regionDist")}
|
||||
dark={dark}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
title={t("growthTrend")}
|
||||
dark={dark}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-8 text-center">
|
||||
{t("demoHint")}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
|
||||
export default function ReportPage() {
|
||||
@@ -18,6 +18,12 @@ export default function ReportPage() {
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<Link
|
||||
href="/report/data"
|
||||
className="inline-flex items-center gap-2 mt-4 px-4 py-2 rounded-xl border border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
📊 {t("dataReport")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!generated ? (
|
||||
|
||||
20
app/api/geo/china/route.ts
Normal file
20
app/api/geo/china/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* 代理中国地图 GeoJSON,避免前端 CORS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json"
|
||||
);
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch china map:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load map" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { City } from "../data/cities";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
function ratingToPercent(rating: string): number {
|
||||
const m: Record<string, number> = {
|
||||
@@ -55,6 +56,7 @@ interface CityCardProps {
|
||||
|
||||
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { t } = useTranslation("cityCard");
|
||||
|
||||
const scores = [
|
||||
{
|
||||
@@ -130,7 +132,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||
👥 {city.nomadsNow}人在此
|
||||
👥 {city.nomadsNow}{t("nomadsHere")}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||
🌡 {city.temperature}°C
|
||||
@@ -141,7 +143,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
{city.description}
|
||||
</span>
|
||||
<span className="font-semibold shrink-0">
|
||||
¥{city.costPerMonth.toLocaleString()}/月
|
||||
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,37 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import { allTags, FILTER_OPTIONS } from "../data/cities";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
interface FilterBarProps {
|
||||
activeFilter: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
sortBy: string;
|
||||
onSortChange: (sort: string) => void;
|
||||
resultCount: number;
|
||||
}
|
||||
const TAG_TO_I18N: Record<string, string> = {
|
||||
全部: "all",
|
||||
热门: "hot",
|
||||
便宜: "cheap",
|
||||
高速网络: "fastInternet",
|
||||
温暖: "warm",
|
||||
安全: "safe",
|
||||
海滨: "beach",
|
||||
山城: "mountain",
|
||||
都市: "urban",
|
||||
美食: "food",
|
||||
文化: "culture",
|
||||
一线城市: "tier1",
|
||||
新一线: "newTier1",
|
||||
宜居: "livable",
|
||||
历史名城: "historical",
|
||||
友好社区: "friendly",
|
||||
户外运动: "outdoor",
|
||||
};
|
||||
|
||||
const SORT_TO_I18N: Record<string, string> = {
|
||||
score: "sortByScore",
|
||||
"cost-asc": "sortByCostAsc",
|
||||
"cost-desc": "sortByCostDesc",
|
||||
internet: "sortByInternet",
|
||||
safety: "sortBySafety",
|
||||
nomads: "sortByNomads",
|
||||
temperature: "sortByTemp",
|
||||
};
|
||||
|
||||
const tagIcons: Record<string, string> = {
|
||||
全部: "🌍",
|
||||
@@ -31,6 +54,14 @@ const tagIcons: Record<string, string> = {
|
||||
户外运动: "🏄",
|
||||
};
|
||||
|
||||
interface FilterBarProps {
|
||||
activeFilter: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
sortBy: string;
|
||||
onSortChange: (sort: string) => void;
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
function FilterBarComponent({
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
@@ -38,48 +69,50 @@ function FilterBarComponent({
|
||||
onSortChange,
|
||||
resultCount,
|
||||
}: FilterBarProps) {
|
||||
const { t } = useTranslation("filter");
|
||||
|
||||
return (
|
||||
<div className="mb-6" id="cities">
|
||||
{/* Top bar: result count + sort */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
发现目的地
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("discoverDest")}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{resultCount} 个城市
|
||||
{resultCount} {t("citiesCount")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 hidden sm:block">排序:</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{t("sortLabel")}:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortChange(e.target.value)}
|
||||
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 cursor-pointer"
|
||||
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 sm:py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] cursor-pointer min-h-[2.5rem] sm:min-h-0"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(SORT_TO_I18N[opt.value] ?? opt.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tags */}
|
||||
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2">
|
||||
{/* Filter tags - horizontal scroll on mobile */}
|
||||
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2 -mx-1 px-1">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onFilterChange(tag)}
|
||||
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium border transition-all ${
|
||||
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-2 sm:py-1.5 rounded-full text-sm font-medium border transition-all min-h-[2.5rem] sm:min-h-0 ${
|
||||
activeFilter === tag
|
||||
? "active border-gray-900 dark:border-gray-100"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
|
||||
<span>{tag}</span>
|
||||
<span>{TAG_TO_I18N[tag] ? t(`tags.${TAG_TO_I18N[tag]}`) : tag}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
|
||||
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
|
||||
|
||||
const avatarPhotos = [
|
||||
"https://i.pravatar.cc/150?img=32",
|
||||
"https://i.pravatar.cc/150?img=33",
|
||||
@@ -29,12 +32,13 @@ const features = [
|
||||
export default function HeroSection() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tHero } = useTranslation("hero");
|
||||
const locale = useLocale();
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const handleJoin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (email.trim()) {
|
||||
alert(`欢迎加入游牧中国!我们将发送确认邮件到 ${email}`);
|
||||
alert(locale === "zh" ? `欢迎加入游牧中国!我们将发送确认邮件到 ${email}` : `Welcome! We'll send a confirmation email to ${email}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,29 +98,29 @@ export default function HeroSection() {
|
||||
key={i}
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<strong className="text-gray-900 dark:text-gray-100">5,000+</strong> 成员已加入
|
||||
{tHero("membersJoined")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
媒体报道
|
||||
{tHero("mediaPress")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
{["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"].map(
|
||||
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
|
||||
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
|
||||
(name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
|
||||
>
|
||||
<span
|
||||
key={name}
|
||||
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,50 +147,50 @@ export default function HeroSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleJoin} className="p-5">
|
||||
<form onSubmit={handleJoin} className="p-4 sm:p-5">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="输入你的邮箱..."
|
||||
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
|
||||
placeholder={tHero("emailPlaceholder")}
|
||||
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
|
||||
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
|
||||
>
|
||||
加入游牧中国 →
|
||||
{tHero("ctaJoin")}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
||||
已有账号?输入邮箱即可自动登录
|
||||
{tHero("loginHint")}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📍 下一站
|
||||
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📍 {tHero("quickLinks.nextStop")}
|
||||
</Link>
|
||||
<Link href="/meetups" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
🍹 线下聚会
|
||||
<Link href="/meetups" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
🍹 {tHero("quickLinks.meetups")}
|
||||
</Link>
|
||||
<Link href="/dating" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
❤️ 智能匹配
|
||||
<Link href="/dating" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
❤️ {tHero("quickLinks.dating")}
|
||||
</Link>
|
||||
<Link href="/tools" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📊 工具箱
|
||||
<Link href="/tools" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📊 {tHero("quickLinks.tools")}
|
||||
</Link>
|
||||
<Link href="/ai" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
🤖 AI 助理
|
||||
<Link href="/ai" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
🤖 {tHero("quickLinks.ai")}
|
||||
</Link>
|
||||
<Link href="/gigs" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
💰 赏金墙
|
||||
<Link href="/gigs" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
💰 {tHero("quickLinks.gigs")}
|
||||
</Link>
|
||||
<Link href="/report" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📊 年报
|
||||
<Link href="/report" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📊 {tHero("quickLinks.report")}
|
||||
</Link>
|
||||
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
💬 加入社群
|
||||
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
💬 {tHero("quickLinks.join")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,21 +46,12 @@ export default function NavbarComponent() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-1 overflow-x-auto max-w-[calc(100vw-400px)] scrollbar-hide">
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors border border-gray-200 dark:border-gray-700 whitespace-nowrap"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span className="hidden xl:inline">{tNav("menu")}</span>
|
||||
</button>
|
||||
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
|
||||
{navLinks.slice(0, 5).map((link) => (
|
||||
<Link
|
||||
key={link.labelKey}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
|
||||
isActive(link.href)
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
@@ -70,6 +61,23 @@ export default function NavbarComponent() {
|
||||
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
|
||||
menuOpen
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
<span className="hidden xl:inline">{tNav("menu")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
@@ -77,8 +85,11 @@ export default function NavbarComponent() {
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
{userEmail ? (
|
||||
|
||||
17
app/components/SetLangAttr.tsx
Normal file
17
app/components/SetLangAttr.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 根据当前 locale 设置 document.documentElement.lang,以支持无障碍与 SEO
|
||||
*/
|
||||
export default function SetLangAttr() {
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
}, [locale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { Link, usePathname, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
interface MenuItem {
|
||||
labelKey: string;
|
||||
@@ -30,7 +29,6 @@ export default memo(function SiteMenu({
|
||||
const { t } = useTranslation("menu");
|
||||
const pathname = usePathname();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,32 +56,27 @@ export default memo(function SiteMenu({
|
||||
{
|
||||
titleKey: "community",
|
||||
items: [
|
||||
{ labelKey: "dating", href: "/dating", icon: "❤️" },
|
||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups", icon: "🎤", new: true },
|
||||
{ labelKey: "friendFinder", href: "/dating", icon: "💛" },
|
||||
{ labelKey: "membersMap", href: "#", icon: "🌐" },
|
||||
{ labelKey: "attendMeetup", href: "/meetups", icon: "🍹" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "tools",
|
||||
items: [
|
||||
{ labelKey: "explore", href: "/", icon: "📍" },
|
||||
{ labelKey: "nextStop", href: "/dashboard", icon: "🗺️" },
|
||||
{ labelKey: "climateFinder", href: "/tools", icon: "🌤️" },
|
||||
{ labelKey: "fireCalculator", href: "/tools", icon: "🌱" },
|
||||
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
||||
{ labelKey: "aiAssistant", href: "/ai", icon: "🤖", new: true },
|
||||
{ labelKey: "yearInReview", href: "/report", icon: "🥂" },
|
||||
{ labelKey: "nomadStats", href: "/report", icon: "📊", new: true },
|
||||
{ labelKey: "nomadStats", href: "/report/data", icon: "📊", new: true },
|
||||
{ labelKey: "gigBoard", href: "/gigs", icon: "💰" },
|
||||
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "help",
|
||||
items: [
|
||||
{ labelKey: "ideasBugs", href: "#", icon: "💡" },
|
||||
{ labelKey: "ideasBugs", href: "/feedback", icon: "💡" },
|
||||
{ labelKey: "faq", href: "#", icon: "🆘" },
|
||||
{ labelKey: "terms", href: "#", icon: "📄" },
|
||||
{ labelKey: "changelog", href: "#", icon: "🚀" },
|
||||
@@ -106,14 +99,7 @@ export default memo(function SiteMenu({
|
||||
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
|
||||
];
|
||||
|
||||
const filteredSections = sections.map((sec) => ({
|
||||
...sec,
|
||||
items: search
|
||||
? sec.items.filter((i) =>
|
||||
t(i.labelKey).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: sec.items,
|
||||
})).filter((sec) => sec.items.length > 0);
|
||||
const filteredSections = sections;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -124,59 +110,7 @@ export default memo(function SiteMenu({
|
||||
ref={ref}
|
||||
className="fixed left-4 right-4 top-20 md:left-auto md:right-4 md:top-16 md:w-[480px] z-50 rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-100 dark:border-gray-800 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{userEmail ? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[#ff4d4f] to-orange-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
{userEmail[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{userEmail}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-4 py-2 rounded-lg bg-[#ff4d4f] text-white text-sm font-medium hover:bg-[#ff3333] transition-colors"
|
||||
>
|
||||
{t("login")}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t("filters")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/join"
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-full bg-[#ff4d4f] text-white flex items-center justify-center hover:bg-[#ff3333] transition-colors shrink-0"
|
||||
aria-label={t("add")}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile + Dark mode */}
|
||||
{/* Profile links */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 flex flex-wrap items-center gap-2">
|
||||
{profileItems.map((item) => (
|
||||
<Link
|
||||
@@ -189,10 +123,6 @@ export default memo(function SiteMenu({
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t("darkMode")}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useTheme } from "@/app/context/ThemeContext";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useTranslation("common");
|
||||
const isDark = theme === "dark";
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -17,7 +19,7 @@ export default function ThemeToggle() {
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="切换主题"
|
||||
aria-label={t("themeLight")}
|
||||
>
|
||||
<div className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -29,8 +31,8 @@ export default function ThemeToggle() {
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
aria-label={isDark ? "切换到日间模式" : "切换到夜间模式"}
|
||||
title={isDark ? "日间模式" : "夜间模式"}
|
||||
aria-label={isDark ? t("themeDark") : t("themeLight")}
|
||||
title={isDark ? t("themeDark") : t("themeLight")}
|
||||
>
|
||||
{isDark ? (
|
||||
<svg
|
||||
|
||||
38
app/data/map-cities.ts
Normal file
38
app/data/map-cities.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 成员地图用城市数据(含坐标)
|
||||
* 中国主要城市近似经纬度
|
||||
*/
|
||||
export interface MapCity {
|
||||
id: number;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
emoji: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
nomadsNow: number;
|
||||
}
|
||||
|
||||
export const MAP_CITIES: MapCity[] = [
|
||||
{ id: 1, name: "大理", nameEn: "Dali", emoji: "🏯", lat: 25.7, lng: 100.2, nomadsNow: 520 },
|
||||
{ id: 2, name: "成都", nameEn: "Chengdu", emoji: "🐼", lat: 30.6, lng: 104.1, nomadsNow: 680 },
|
||||
{ id: 3, name: "深圳", nameEn: "Shenzhen", emoji: "🌃", lat: 22.5, lng: 114.1, nomadsNow: 850 },
|
||||
{ id: 4, name: "上海", nameEn: "Shanghai", emoji: "🏙️", lat: 31.2, lng: 121.5, nomadsNow: 1200 },
|
||||
{ id: 5, name: "杭州", nameEn: "Hangzhou", emoji: "⛲", lat: 30.2, lng: 120.2, nomadsNow: 560 },
|
||||
{ id: 6, name: "厦门", nameEn: "Xiamen", emoji: "🌊", lat: 24.5, lng: 118.1, nomadsNow: 320 },
|
||||
{ id: 7, name: "北京", nameEn: "Beijing", emoji: "🏛️", lat: 39.9, lng: 116.4, nomadsNow: 780 },
|
||||
{ id: 8, name: "广州", nameEn: "Guangzhou", emoji: "🌺", lat: 23.1, lng: 113.3, nomadsNow: 480 },
|
||||
{ id: 9, name: "重庆", nameEn: "Chongqing", emoji: "🌶️", lat: 29.6, lng: 106.6, nomadsNow: 350 },
|
||||
{ id: 10, name: "昆明", nameEn: "Kunming", emoji: "🌸", lat: 25.0, lng: 102.7, nomadsNow: 380 },
|
||||
{ id: 11, name: "三亚", nameEn: "Sanya", emoji: "🏖️", lat: 18.3, lng: 109.5, nomadsNow: 250 },
|
||||
{ id: 12, name: "丽江", nameEn: "Lijiang", emoji: "🏔️", lat: 26.9, lng: 100.2, nomadsNow: 280 },
|
||||
{ id: 13, name: "西安", nameEn: "Xi'an", emoji: "🗿", lat: 34.3, lng: 108.9, nomadsNow: 230 },
|
||||
{ id: 14, name: "南京", nameEn: "Nanjing", emoji: "🍃", lat: 32.1, lng: 118.8, nomadsNow: 400 },
|
||||
{ id: 15, name: "长沙", nameEn: "Changsha", emoji: "🎪", lat: 28.2, lng: 112.9, nomadsNow: 290 },
|
||||
{ id: 16, name: "青岛", nameEn: "Qingdao", emoji: "🍺", lat: 36.1, lng: 120.4, nomadsNow: 220 },
|
||||
{ id: 17, name: "苏州", nameEn: "Suzhou", emoji: "🏡", lat: 31.3, lng: 120.6, nomadsNow: 260 },
|
||||
{ id: 18, name: "珠海", nameEn: "Zhuhai", emoji: "🎡", lat: 22.3, lng: 113.6, nomadsNow: 180 },
|
||||
];
|
||||
|
||||
export const MAP_BOUNDS = {
|
||||
china: { latMin: 18, latMax: 54, lngMin: 73, lngMax: 135 },
|
||||
};
|
||||
@@ -32,6 +32,7 @@ html {
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@@ -40,6 +41,37 @@ html {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ==================== Container (responsive) ==================== */
|
||||
.container-nomad {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.container-nomad {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.container-nomad {
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Items Grid (mobile-first) ==================== */
|
||||
.items-grid {
|
||||
|
||||
135
app/lib/charts/MembersMap.tsx
Normal file
135
app/lib/charts/MembersMap.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as echarts from "echarts";
|
||||
import type { MapCity } from "./types";
|
||||
|
||||
const CHINA_MAP_URL = "/api/geo/china";
|
||||
|
||||
export interface MembersMapProps {
|
||||
data: MapCity[];
|
||||
locale?: "zh" | "en";
|
||||
dark?: boolean;
|
||||
className?: string;
|
||||
onCityHover?: (city: MapCity | null) => void;
|
||||
}
|
||||
|
||||
export function MembersMap({
|
||||
data,
|
||||
locale = "zh",
|
||||
dark = false,
|
||||
className = "",
|
||||
onCityHover,
|
||||
}: MembersMapProps) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<echarts.ECharts | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || data.length === 0) return;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const res = await fetch(CHINA_MAP_URL);
|
||||
const geoJson = await res.json();
|
||||
echarts.registerMap("china", geoJson);
|
||||
} catch {
|
||||
// 若加载失败,使用 scatter 在 geo 坐标系(无底图)
|
||||
echarts.registerMap("china", {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
|
||||
const chart = echarts.init(chartRef.current!);
|
||||
instanceRef.current = chart;
|
||||
|
||||
const scatterData = data.map((c) => ({
|
||||
name: locale === "zh" ? c.name : c.nameEn,
|
||||
value: [c.lng, c.lat, c.nomadsNow],
|
||||
id: c.id,
|
||||
}));
|
||||
|
||||
const maxVal = Math.max(...data.map((c) => c.nomadsNow), 1);
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: unknown) => {
|
||||
const p = params as { data: { value: [number, number, number]; name: string } };
|
||||
return `${p.data.name}<br/>${p.data.value[2]} 人在此`;
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
map: "china",
|
||||
roam: true,
|
||||
zoom: 1.2,
|
||||
itemStyle: {
|
||||
areaColor: dark ? "#1e293b" : "#f1f5f9",
|
||||
borderColor: dark ? "#334155" : "#e2e8f0",
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: dark ? "#334155" : "#e2e8f0",
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "scatter",
|
||||
coordinateSystem: "geo",
|
||||
data: scatterData,
|
||||
symbolSize: (val: number[]) => 8 + (val[2] / maxVal) * 24,
|
||||
itemStyle: {
|
||||
color: "#ff4d4f",
|
||||
borderColor: "#fff",
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
scale: 1.3,
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
setReady(true);
|
||||
|
||||
chart.on("mouseover", (params: { data?: { id?: number } }) => {
|
||||
const id = params.data?.id;
|
||||
const city = id ? (data.find((c) => c.id === id) ?? null) : null;
|
||||
onCityHover?.(city);
|
||||
});
|
||||
chart.on("mouseout", () => onCityHover?.(null));
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
instanceRef.current?.dispose();
|
||||
instanceRef.current = null;
|
||||
};
|
||||
}, [data, locale, onCityHover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceRef.current || !ready) return;
|
||||
instanceRef.current.resize();
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => instanceRef.current?.resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chartRef}
|
||||
className={className}
|
||||
style={{ minHeight: 360, width: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
app/lib/charts/README.md
Normal file
66
app/lib/charts/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 图表 SDK
|
||||
|
||||
基于 ECharts 封装的图表组件库,供项目统一调用。
|
||||
|
||||
## 组件
|
||||
|
||||
### MembersMap 成员地图
|
||||
|
||||
中国地图 + 城市散点,展示成员分布。
|
||||
|
||||
```tsx
|
||||
import { MembersMap } from "@/app/lib/charts";
|
||||
import { MAP_CITIES } from "@/app/data/map-cities";
|
||||
|
||||
<MembersMap
|
||||
data={MAP_CITIES}
|
||||
locale="zh"
|
||||
dark={false}
|
||||
onCityHover={(city) => setHoveredCity(city)}
|
||||
/>
|
||||
```
|
||||
|
||||
### BarChart 柱状图
|
||||
|
||||
```tsx
|
||||
import { BarChart } from "@/app/lib/charts";
|
||||
|
||||
<BarChart
|
||||
data={[{ name: "上海", value: 1200 }, { name: "深圳", value: 850 }]}
|
||||
title="城市排名"
|
||||
dark={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### PieChart 饼图
|
||||
|
||||
```tsx
|
||||
import { PieChart } from "@/app/lib/charts";
|
||||
|
||||
<PieChart
|
||||
data={[{ name: "华东", value: 3200 }, { name: "华南", value: 1800 }]}
|
||||
title="区域分布"
|
||||
dark={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### LineChart 折线图
|
||||
|
||||
```tsx
|
||||
import { LineChart } from "@/app/lib/charts";
|
||||
|
||||
<LineChart
|
||||
data={{
|
||||
xAxis: ["1月", "2月", "3月"],
|
||||
series: [{ name: "新增成员", data: [320, 480, 520] }],
|
||||
}}
|
||||
title="增长趋势"
|
||||
dark={false}
|
||||
/>
|
||||
```
|
||||
|
||||
## 类型
|
||||
|
||||
- `MapCity`: 地图城市数据
|
||||
- `BarChartDataItem`: 柱状图数据项
|
||||
- `PieChartDataItem`: 饼图数据项
|
||||
156
app/lib/charts/ReportChart.tsx
Normal file
156
app/lib/charts/ReportChart.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts";
|
||||
import type { BarChartDataItem, PieChartDataItem } from "./types";
|
||||
|
||||
export interface BarChartProps {
|
||||
data: BarChartDataItem[];
|
||||
title?: string;
|
||||
dark?: boolean;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
title,
|
||||
dark = false,
|
||||
color = "#ff4d4f",
|
||||
className = "",
|
||||
}: BarChartProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || data.length === 0) return;
|
||||
|
||||
const chart = echarts.init(ref.current);
|
||||
const textColor = dark ? "#e2e8f0" : "#334155";
|
||||
|
||||
chart.setOption({
|
||||
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: "3%", right: "4%", bottom: "3%", top: title ? 40 : 20, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data.map((d) => d.name),
|
||||
axisLabel: { color: textColor },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: { color: textColor },
|
||||
splitLine: { lineStyle: { color: dark ? "#334155" : "#e2e8f0" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: data.map((d) => d.value),
|
||||
itemStyle: { color },
|
||||
barMaxWidth: 36,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return () => chart.dispose();
|
||||
}, [data, title, dark, color]);
|
||||
|
||||
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
|
||||
}
|
||||
|
||||
export interface PieChartProps {
|
||||
data: PieChartDataItem[];
|
||||
title?: string;
|
||||
dark?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PIE_COLORS = ["#ff4d4f", "#f97316", "#eab308", "#22c55e", "#0ea5e9", "#8b5cf6"];
|
||||
|
||||
export function PieChart({
|
||||
data,
|
||||
title,
|
||||
dark = false,
|
||||
className = "",
|
||||
}: PieChartProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || data.length === 0) return;
|
||||
|
||||
const chart = echarts.init(ref.current);
|
||||
const textColor = dark ? "#e2e8f0" : "#334155";
|
||||
|
||||
chart.setOption({
|
||||
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
|
||||
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
|
||||
legend: { bottom: 0, textStyle: { color: textColor } },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["40%", "70%"],
|
||||
center: ["50%", title ? "45%" : "50%"],
|
||||
data: data.map((d, i) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] },
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return () => chart.dispose();
|
||||
}, [data, title, dark]);
|
||||
|
||||
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
|
||||
}
|
||||
|
||||
export interface LineChartProps {
|
||||
data: { xAxis: string[]; series: { name: string; data: number[] }[] };
|
||||
title?: string;
|
||||
dark?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
title,
|
||||
dark = false,
|
||||
className = "",
|
||||
}: LineChartProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !data.xAxis?.length) return;
|
||||
|
||||
const chart = echarts.init(ref.current);
|
||||
const textColor = dark ? "#e2e8f0" : "#334155";
|
||||
|
||||
chart.setOption({
|
||||
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { bottom: 0, textStyle: { color: textColor } },
|
||||
grid: { left: "3%", right: "4%", bottom: 60, top: title ? 40 : 20, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data.xAxis,
|
||||
axisLabel: { color: textColor },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: { color: textColor },
|
||||
splitLine: { lineStyle: { color: dark ? "#334155" : "#e2e8f0" } },
|
||||
},
|
||||
series: data.series.map((s, i) => ({
|
||||
type: "line",
|
||||
name: s.name,
|
||||
data: s.data,
|
||||
smooth: true,
|
||||
itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] },
|
||||
})),
|
||||
});
|
||||
|
||||
return () => chart.dispose();
|
||||
}, [data, title, dark]);
|
||||
|
||||
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
|
||||
}
|
||||
16
app/lib/charts/index.ts
Normal file
16
app/lib/charts/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 图表 SDK - 基于 ECharts 封装
|
||||
* 提供成员地图、柱状图、饼图、折线图等组件
|
||||
*/
|
||||
|
||||
export { MembersMap } from "./MembersMap";
|
||||
export { BarChart, PieChart, LineChart } from "./ReportChart";
|
||||
export type {
|
||||
MapCity,
|
||||
ChartOption,
|
||||
BarChartDataItem,
|
||||
PieChartDataItem,
|
||||
LineChartDataItem,
|
||||
} from "./types";
|
||||
export type { MembersMapProps } from "./MembersMap";
|
||||
export type { BarChartProps, PieChartProps, LineChartProps } from "./ReportChart";
|
||||
33
app/lib/charts/types.ts
Normal file
33
app/lib/charts/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 图表 SDK 类型定义
|
||||
*/
|
||||
|
||||
export interface MapCity {
|
||||
id: number;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
emoji: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
nomadsNow: number;
|
||||
}
|
||||
|
||||
export interface ChartOption {
|
||||
title?: string;
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface PieChartDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LineChartDataItem {
|
||||
name: string;
|
||||
data: number[];
|
||||
}
|
||||
129
messages/en.json
129
messages/en.json
@@ -7,7 +7,9 @@
|
||||
"logout": "Logout",
|
||||
"loginRegister": "Login / Sign up",
|
||||
"explore": "Explore →",
|
||||
"toggleMenu": "Toggle menu"
|
||||
"toggleMenu": "Toggle menu",
|
||||
"themeLight": "Switch to dark mode",
|
||||
"themeDark": "Switch to light mode"
|
||||
},
|
||||
"menu": {
|
||||
"searchPlaceholder": "Search or filter",
|
||||
@@ -19,6 +21,7 @@
|
||||
"yourProfile": "Your profile",
|
||||
"settings": "Settings",
|
||||
"favorites": "Favorites",
|
||||
"pricing": "Pricing",
|
||||
"nomadInsurance": "Nomad Insurance",
|
||||
"community": "Community",
|
||||
"tools": "Tools",
|
||||
@@ -48,7 +51,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"cities": "Cities",
|
||||
"menu": "Menu",
|
||||
"menu": "More",
|
||||
"ai": "AI",
|
||||
"travel": "Travel",
|
||||
"community": "Community",
|
||||
@@ -70,6 +73,20 @@
|
||||
"feature4": "Gig board: post/claim tasks, platform fee 10-15%",
|
||||
"feature5": "Nomad report: countries visited? Generate share card",
|
||||
"ctaJoin": "Join Nomad China →",
|
||||
"emailPlaceholder": "Enter your email...",
|
||||
"loginHint": "Have an account? Enter email to auto-login",
|
||||
"membersJoined": "5,000+ members joined",
|
||||
"mediaPress": "Press Coverage",
|
||||
"quickLinks": {
|
||||
"nextStop": "Next Stop",
|
||||
"meetups": "Meetups",
|
||||
"dating": "Smart Match",
|
||||
"tools": "Toolkit",
|
||||
"ai": "AI Assistant",
|
||||
"gigs": "Gig Board",
|
||||
"report": "Report",
|
||||
"join": "Join Community"
|
||||
},
|
||||
"stats": {
|
||||
"cities": "Cities",
|
||||
"nomads": "Nomads",
|
||||
@@ -77,6 +94,37 @@
|
||||
"cost": "Monthly cost from"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"discoverDest": "Discover Destinations",
|
||||
"citiesCount": "cities",
|
||||
"sortLabel": "Sort",
|
||||
"sortByScore": "Overall Score",
|
||||
"sortByCostAsc": "Lowest Cost",
|
||||
"sortByCostDesc": "Highest Cost",
|
||||
"sortByInternet": "Fastest Internet",
|
||||
"sortBySafety": "Safest",
|
||||
"sortByNomads": "Most Nomads",
|
||||
"sortByTemp": "Warmest",
|
||||
"tags": {
|
||||
"all": "All",
|
||||
"hot": "Popular",
|
||||
"cheap": "Budget",
|
||||
"fastInternet": "Fast Internet",
|
||||
"warm": "Warm",
|
||||
"safe": "Safe",
|
||||
"beach": "Beach",
|
||||
"mountain": "Mountain",
|
||||
"urban": "Urban",
|
||||
"food": "Food",
|
||||
"culture": "Culture",
|
||||
"tier1": "Tier 1",
|
||||
"newTier1": "New Tier 1",
|
||||
"livable": "Livable",
|
||||
"historical": "Historical",
|
||||
"friendly": "Friendly",
|
||||
"outdoor": "Outdoor"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"stats": {
|
||||
"cities": "Cities",
|
||||
@@ -114,7 +162,11 @@
|
||||
"cityGuides": "City Guides",
|
||||
"viewGuides": "View guides →",
|
||||
"noMatch": "No matching cities found",
|
||||
"showAll": "Show all cities"
|
||||
"showAll": "Show all cities",
|
||||
"meetupDate": "Mar 15",
|
||||
"meetupLocation": "Shenzhen Nanshan",
|
||||
"meetupPeople": "12 people",
|
||||
"joinedThisMonthShort": "86 joined this month"
|
||||
},
|
||||
"footer": {
|
||||
"community": "Community",
|
||||
@@ -165,6 +217,10 @@
|
||||
},
|
||||
"copyright": "© 2024-2026 Nomad China. All rights reserved."
|
||||
},
|
||||
"cityCard": {
|
||||
"nomadsHere": "nomads here",
|
||||
"perMonth": "mo"
|
||||
},
|
||||
"cityModal": {
|
||||
"breadcrumbPrefix": "Cost of Living",
|
||||
"reviews": "reviews",
|
||||
@@ -230,6 +286,24 @@
|
||||
"region": "Asia",
|
||||
"map": "Map"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Ideas & Feedback",
|
||||
"subtitle": "Help us improve. Your voice matters.",
|
||||
"type": "Type",
|
||||
"suggestion": "Suggestion",
|
||||
"bug": "Bug Report",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Brief description",
|
||||
"content": "Details",
|
||||
"contentPlaceholder": "Please describe your idea or the issue in detail...",
|
||||
"submit": "Submit",
|
||||
"demoHint": "Demo mode. Will submit after backend integration.",
|
||||
"successTitle": "Thank you for your feedback!",
|
||||
"successDesc": "We've received your submission and will review it soon.",
|
||||
"submitAnother": "Submit another"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Login / Sign up",
|
||||
"login": "Login",
|
||||
@@ -347,8 +421,19 @@
|
||||
"examples": "Example questions",
|
||||
"comingSoon": "AI feature coming soon"
|
||||
},
|
||||
"dataReport": {
|
||||
"title": "Data Report",
|
||||
"subtitle": "Member distribution, city rankings, growth trends",
|
||||
"backToReport": "Back to report",
|
||||
"membersMap": "Members Map",
|
||||
"cityRanking": "City Ranking Top 10",
|
||||
"regionDist": "Region Distribution",
|
||||
"growthTrend": "Growth Trend",
|
||||
"demoHint": "Demo data. Real stats will show after database integration"
|
||||
},
|
||||
"report": {
|
||||
"title": "Nomad Year Report",
|
||||
"dataReport": "Data Report",
|
||||
"desc": "Auto stats: countries visited, avg monthly cost, coworking check-ins",
|
||||
"yearTitle": "2025 Nomad Report",
|
||||
"countries": "Countries",
|
||||
@@ -379,6 +464,44 @@
|
||||
"notFound": "City guide not found",
|
||||
"backToHome": "Back to Home"
|
||||
},
|
||||
"meetups": {
|
||||
"title": "Offline Meetups",
|
||||
"subtitle": "Connect with digital nomads worldwide, expand your network",
|
||||
"hostMeetup": "Host Meetup",
|
||||
"upcoming": "Upcoming",
|
||||
"previous": "Past"
|
||||
},
|
||||
"hostMeetup": {
|
||||
"title": "Host Meetup",
|
||||
"subtitle": "Create an offline event and gather local nomads",
|
||||
"backToMeetups": "Back to meetups",
|
||||
"city": "City",
|
||||
"selectCity": "Select city",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"venue": "Venue name",
|
||||
"venuePlaceholder": "e.g. Café, coworking space",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "Street, building number",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Brief description of the event",
|
||||
"maxAttendees": "Max attendees",
|
||||
"submit": "Publish meetup",
|
||||
"demoHint": "Demo mode: data is not saved. Will work with database integration.",
|
||||
"successTitle": "Published!",
|
||||
"successDesc": "Your meetup has been submitted. It will appear in the list after review.",
|
||||
"createAnother": "Create another",
|
||||
"viewMeetups": "View meetups"
|
||||
},
|
||||
"membersMap": {
|
||||
"title": "Members Map",
|
||||
"subtitle": "See where digital nomads are",
|
||||
"nomadsInCity": "nomads here",
|
||||
"totalMembers": "Total members",
|
||||
"cities": "Cities",
|
||||
"noData": "No member data yet",
|
||||
"demoHint": "Demo data. Real member locations will show after database integration"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"subtitle": "Premium services for digital nomads",
|
||||
|
||||
129
messages/zh.json
129
messages/zh.json
@@ -7,7 +7,9 @@
|
||||
"logout": "退出",
|
||||
"loginRegister": "登录 / 注册",
|
||||
"explore": "开始探索 →",
|
||||
"toggleMenu": "切换菜单"
|
||||
"toggleMenu": "切换菜单",
|
||||
"themeLight": "切换到夜间模式",
|
||||
"themeDark": "切换到日间模式"
|
||||
},
|
||||
"menu": {
|
||||
"searchPlaceholder": "搜索或筛选",
|
||||
@@ -19,6 +21,7 @@
|
||||
"yourProfile": "个人资料",
|
||||
"settings": "设置",
|
||||
"favorites": "收藏",
|
||||
"pricing": "会员",
|
||||
"nomadInsurance": "游民保险",
|
||||
"community": "社区",
|
||||
"tools": "工具",
|
||||
@@ -48,7 +51,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"cities": "城市",
|
||||
"menu": "功能菜单",
|
||||
"menu": "更多",
|
||||
"ai": "AI",
|
||||
"travel": "旅行",
|
||||
"community": "社区",
|
||||
@@ -70,6 +73,20 @@
|
||||
"feature4": "赏金墙:发布/接单小任务,平台抽成 10-15%",
|
||||
"feature5": "Nomad 年报:今年飞了多少国家?生成分享卡片",
|
||||
"ctaJoin": "加入游牧中国 →",
|
||||
"emailPlaceholder": "输入你的邮箱...",
|
||||
"loginHint": "已有账号?输入邮箱即可自动登录",
|
||||
"membersJoined": "5,000+ 成员已加入",
|
||||
"mediaPress": "媒体报道",
|
||||
"quickLinks": {
|
||||
"nextStop": "下一站",
|
||||
"meetups": "线下聚会",
|
||||
"dating": "智能匹配",
|
||||
"tools": "工具箱",
|
||||
"ai": "AI 助理",
|
||||
"gigs": "赏金墙",
|
||||
"report": "年报",
|
||||
"join": "加入社群"
|
||||
},
|
||||
"stats": {
|
||||
"cities": "个城市",
|
||||
"nomads": "位游民",
|
||||
@@ -77,6 +94,37 @@
|
||||
"cost": "起/月生活费"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"discoverDest": "发现目的地",
|
||||
"citiesCount": "个城市",
|
||||
"sortLabel": "排序",
|
||||
"sortByScore": "综合评分",
|
||||
"sortByCostAsc": "费用最低",
|
||||
"sortByCostDesc": "费用最高",
|
||||
"sortByInternet": "网速最快",
|
||||
"sortBySafety": "最安全",
|
||||
"sortByNomads": "游民最多",
|
||||
"sortByTemp": "最温暖",
|
||||
"tags": {
|
||||
"all": "全部",
|
||||
"hot": "热门",
|
||||
"cheap": "便宜",
|
||||
"fastInternet": "高速网络",
|
||||
"warm": "温暖",
|
||||
"safe": "安全",
|
||||
"beach": "海滨",
|
||||
"mountain": "山城",
|
||||
"urban": "都市",
|
||||
"food": "美食",
|
||||
"culture": "文化",
|
||||
"tier1": "一线城市",
|
||||
"newTier1": "新一线",
|
||||
"livable": "宜居",
|
||||
"historical": "历史名城",
|
||||
"friendly": "友好社区",
|
||||
"outdoor": "户外运动"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"stats": {
|
||||
"cities": "个城市",
|
||||
@@ -114,7 +162,11 @@
|
||||
"cityGuides": "城市攻略",
|
||||
"viewGuides": "查看攻略 →",
|
||||
"noMatch": "没有找到匹配的城市",
|
||||
"showAll": "显示全部城市"
|
||||
"showAll": "显示全部城市",
|
||||
"meetupDate": "3月15日",
|
||||
"meetupLocation": "深圳南山",
|
||||
"meetupPeople": "12人",
|
||||
"joinedThisMonthShort": "本月 86 人加入"
|
||||
},
|
||||
"footer": {
|
||||
"community": "社区",
|
||||
@@ -165,6 +217,10 @@
|
||||
},
|
||||
"copyright": "© 2024-2026 游牧中国 NomadCNA. All rights reserved."
|
||||
},
|
||||
"cityCard": {
|
||||
"nomadsHere": "人在此",
|
||||
"perMonth": "月"
|
||||
},
|
||||
"cityModal": {
|
||||
"breadcrumbPrefix": "生活成本",
|
||||
"reviews": "条评价",
|
||||
@@ -230,6 +286,24 @@
|
||||
"region": "亚洲",
|
||||
"map": "地图"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "建议与反馈",
|
||||
"subtitle": "帮助我们改进产品,你的声音很重要",
|
||||
"type": "类型",
|
||||
"suggestion": "建议",
|
||||
"bug": "Bug 反馈",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"titleLabel": "标题",
|
||||
"titlePlaceholder": "简要描述问题或建议",
|
||||
"content": "详细内容",
|
||||
"contentPlaceholder": "请详细描述你的想法或遇到的问题...",
|
||||
"submit": "提交",
|
||||
"demoHint": "当前为演示模式,接入后端后将正式发送。",
|
||||
"successTitle": "感谢你的反馈!",
|
||||
"successDesc": "我们已收到你的提交,会尽快处理。",
|
||||
"submitAnother": "再提交一条"
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录 / 注册",
|
||||
"login": "登录",
|
||||
@@ -347,8 +421,19 @@
|
||||
"examples": "示例问题",
|
||||
"comingSoon": "AI 功能即将上线,敬请期待"
|
||||
},
|
||||
"dataReport": {
|
||||
"title": "数据报告",
|
||||
"subtitle": "成员分布、城市排名、增长趋势",
|
||||
"backToReport": "返回年报",
|
||||
"membersMap": "成员分布地图",
|
||||
"cityRanking": "城市成员排名 Top 10",
|
||||
"regionDist": "区域分布",
|
||||
"growthTrend": "增长趋势",
|
||||
"demoHint": "演示数据,接入数据库后将展示真实统计"
|
||||
},
|
||||
"report": {
|
||||
"title": "Nomad 年报",
|
||||
"dataReport": "数据报告",
|
||||
"desc": "自动统计:今年飞了多少国家、平均月生活成本、在多少个 co-working 打卡",
|
||||
"yearTitle": "2025 Nomad 年报",
|
||||
"countries": "国家/地区",
|
||||
@@ -379,6 +464,44 @@
|
||||
"notFound": "城市指南不存在",
|
||||
"backToHome": "返回首页"
|
||||
},
|
||||
"meetups": {
|
||||
"title": "线下聚会",
|
||||
"subtitle": "与全球数字游民面对面交流,拓展人脉,分享经验",
|
||||
"hostMeetup": "发起聚会",
|
||||
"upcoming": "即将举办",
|
||||
"previous": "往期聚会"
|
||||
},
|
||||
"hostMeetup": {
|
||||
"title": "发起聚会",
|
||||
"subtitle": "创建线下活动,召集同城数字游民",
|
||||
"backToMeetups": "返回聚会列表",
|
||||
"city": "城市",
|
||||
"selectCity": "选择城市",
|
||||
"date": "日期",
|
||||
"time": "时间",
|
||||
"venue": "场地名称",
|
||||
"venuePlaceholder": "如:咖啡馆、共享办公空间",
|
||||
"address": "详细地址",
|
||||
"addressPlaceholder": "街道、门牌号",
|
||||
"description": "活动介绍",
|
||||
"descriptionPlaceholder": "简单描述活动主题和形式",
|
||||
"maxAttendees": "人数上限",
|
||||
"submit": "发布聚会",
|
||||
"demoHint": "当前为演示模式,数据不会保存。接入数据库后将正式生效。",
|
||||
"successTitle": "发布成功!",
|
||||
"successDesc": "你的聚会已提交,审核通过后将展示在聚会列表中。",
|
||||
"createAnother": "再发起一个",
|
||||
"viewMeetups": "查看聚会列表"
|
||||
},
|
||||
"membersMap": {
|
||||
"title": "成员地图",
|
||||
"subtitle": "查看各地数字游民分布",
|
||||
"nomadsInCity": "人在此",
|
||||
"totalMembers": "总成员",
|
||||
"cities": "城市",
|
||||
"noData": "暂无成员分布数据",
|
||||
"demoHint": "演示数据,接入数据库后将显示真实成员位置"
|
||||
},
|
||||
"services": {
|
||||
"title": "增值服务",
|
||||
"subtitle": "数字游民专属增值服务",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.0.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "16.1.6",
|
||||
"pocketbase": "^0.26.8",
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
minio:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
@@ -903,6 +906,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
@@ -1924,6 +1930,9 @@ packages:
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -2030,6 +2039,9 @@ packages:
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@@ -2861,6 +2873,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -4085,6 +4102,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
@@ -4245,3 +4264,7 @@ snapshots:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
Reference in New Issue
Block a user