'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 type { Locale } from "../context/LocaleContext";
|
||||||
import { getThemeMessages } from "@/app/lib/theme-data";
|
import { getThemeMessages } from "@/app/lib/theme-data";
|
||||||
import Navbar from "../components/Navbar";
|
import Navbar from "../components/Navbar";
|
||||||
|
import SetLangAttr from "../components/SetLangAttr";
|
||||||
|
|
||||||
const LOCALES: Locale[] = ["zh", "en"];
|
const LOCALES: Locale[] = ["zh", "en"];
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default async function LocaleLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||||
|
<SetLangAttr />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
</LocaleProvider>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { Link, useTranslation } from "@/i18n/navigation";
|
||||||
|
|
||||||
interface Attendee {
|
interface Attendee {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -80,6 +81,7 @@ function MeetupCard({ meetup }: { meetup: Meetup }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MeetupsPage() {
|
export default function MeetupsPage() {
|
||||||
|
const { t } = useTranslation("meetups");
|
||||||
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
|
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
|
||||||
const upcomingMeetups = useMemo(() => allMeetups.filter((m) => m.isUpcoming), []);
|
const upcomingMeetups = useMemo(() => allMeetups.filter((m) => m.isUpcoming), []);
|
||||||
const previousMeetups = useMemo(() => allMeetups.filter((m) => !m.isUpcoming), []);
|
const previousMeetups = useMemo(() => allMeetups.filter((m) => !m.isUpcoming), []);
|
||||||
@@ -89,22 +91,31 @@ export default function MeetupsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
<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">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
|
||||||
<header className="mb-8">
|
<header className="mb-8 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
<div>
|
||||||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
<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>
|
{t("title")}
|
||||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
</h1>
|
||||||
与全球数字游民面对面交流,拓展人脉,分享经验
|
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||||
</p>
|
{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>
|
</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">
|
<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"}`}>
|
<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>
|
||||||
<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"}`}>
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -137,13 +137,13 @@ export default function Home() {
|
|||||||
<div className="right-item-header">{t("latestEvents")}</div>
|
<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="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">
|
<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>
|
||||||
<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">
|
<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>
|
||||||
<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">
|
<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>
|
||||||
<div className="flex -space-x-1 mt-auto">
|
<div className="flex -space-x-1 mt-auto">
|
||||||
{memberPhotos.slice(0, 5).map((m) => (
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "@/i18n/navigation";
|
import { Link, useTranslation } from "@/i18n/navigation";
|
||||||
import Footer from "@/app/components/Footer";
|
import Footer from "@/app/components/Footer";
|
||||||
|
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
@@ -18,6 +18,12 @@ export default function ReportPage() {
|
|||||||
<p className="mt-2 text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-gray-500 dark:text-gray-400">
|
||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!generated ? (
|
{!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 { memo, useState } from "react";
|
||||||
import { City } from "../data/cities";
|
import { City } from "../data/cities";
|
||||||
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
|
|
||||||
function ratingToPercent(rating: string): number {
|
function ratingToPercent(rating: string): number {
|
||||||
const m: Record<string, number> = {
|
const m: Record<string, number> = {
|
||||||
@@ -55,6 +56,7 @@ interface CityCardProps {
|
|||||||
|
|
||||||
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const { t } = useTranslation("cityCard");
|
||||||
|
|
||||||
const scores = [
|
const scores = [
|
||||||
{
|
{
|
||||||
@@ -130,7 +132,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<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">
|
<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>
|
||||||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
<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
|
🌡 {city.temperature}°C
|
||||||
@@ -141,7 +143,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
|||||||
{city.description}
|
{city.description}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold shrink-0">
|
<span className="font-semibold shrink-0">
|
||||||
¥{city.costPerMonth.toLocaleString()}/月
|
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,37 @@
|
|||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { allTags, FILTER_OPTIONS } from "../data/cities";
|
import { allTags, FILTER_OPTIONS } from "../data/cities";
|
||||||
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
|
|
||||||
interface FilterBarProps {
|
const TAG_TO_I18N: Record<string, string> = {
|
||||||
activeFilter: string;
|
全部: "all",
|
||||||
onFilterChange: (filter: string) => void;
|
热门: "hot",
|
||||||
sortBy: string;
|
便宜: "cheap",
|
||||||
onSortChange: (sort: string) => void;
|
高速网络: "fastInternet",
|
||||||
resultCount: number;
|
温暖: "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> = {
|
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({
|
function FilterBarComponent({
|
||||||
activeFilter,
|
activeFilter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
@@ -38,48 +69,50 @@ function FilterBarComponent({
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
resultCount,
|
resultCount,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
|
const { t } = useTranslation("filter");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6" id="cities">
|
<div className="mb-6" id="cities">
|
||||||
{/* Top bar: result count + sort */}
|
{/* 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">
|
<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>
|
</h2>
|
||||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
{resultCount} 个城市
|
{resultCount} {t("citiesCount")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => onSortChange(e.target.value)}
|
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) => (
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(SORT_TO_I18N[opt.value] ?? opt.value)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter tags */}
|
{/* Filter tags - horizontal scroll on mobile */}
|
||||||
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2">
|
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2 -mx-1 px-1">
|
||||||
{allTags.map((tag) => (
|
{allTags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
onClick={() => onFilterChange(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
|
activeFilter === tag
|
||||||
? "active border-gray-900 dark:border-gray-100"
|
? "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"
|
: "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 className="text-xs">{tagIcons[tag] || "🏷️"}</span>
|
||||||
<span>{tag}</span>
|
<span>{TAG_TO_I18N[tag] ? t(`tags.${TAG_TO_I18N[tag]}`) : tag}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link, useLocale } from "@/i18n/navigation";
|
||||||
import { useTranslation } 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 = [
|
const avatarPhotos = [
|
||||||
"https://i.pravatar.cc/150?img=32",
|
"https://i.pravatar.cc/150?img=32",
|
||||||
"https://i.pravatar.cc/150?img=33",
|
"https://i.pravatar.cc/150?img=33",
|
||||||
@@ -29,12 +32,13 @@ const features = [
|
|||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { t: tHero } = useTranslation("hero");
|
const { t: tHero } = useTranslation("hero");
|
||||||
|
const locale = useLocale();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
const handleJoin = (e: React.FormEvent) => {
|
const handleJoin = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (email.trim()) {
|
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}
|
key={i}
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
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>
|
</div>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
|
<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">
|
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
媒体报道
|
{tHero("mediaPress")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
|
||||||
{["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"].map(
|
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
|
||||||
(name) => (
|
(name) => (
|
||||||
<span
|
<span
|
||||||
key={name}
|
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"
|
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}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
),
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,50 +147,50 @@ export default function HeroSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleJoin} className="p-5">
|
<form onSubmit={handleJoin} className="p-4 sm:p-5">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="输入你的邮箱..."
|
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 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"
|
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
|
<button
|
||||||
type="submit"
|
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>
|
</button>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
||||||
已有账号?输入邮箱即可自动登录
|
{tHero("loginHint")}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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">
|
<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">
|
||||||
🤖 AI 助理
|
🤖 {tHero("quickLinks.ai")}
|
||||||
</Link>
|
</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>
|
||||||
<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>
|
||||||
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,21 +46,12 @@ export default function NavbarComponent() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="hidden lg:flex items-center gap-1 overflow-x-auto max-w-[calc(100vw-400px)] scrollbar-hide">
|
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
|
||||||
<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>
|
|
||||||
{navLinks.slice(0, 5).map((link) => (
|
{navLinks.slice(0, 5).map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.labelKey}
|
key={link.labelKey}
|
||||||
href={link.href}
|
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)
|
isActive(link.href)
|
||||||
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
|
? "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"
|
: "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>
|
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<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"
|
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
onClick={() => setMenuOpen(true)}
|
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">
|
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{userEmail ? (
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { memo, useState, useEffect, useRef } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { Link, usePathname, useTranslation } from "@/i18n/navigation";
|
import { Link, usePathname, useTranslation } from "@/i18n/navigation";
|
||||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -30,7 +29,6 @@ export default memo(function SiteMenu({
|
|||||||
const { t } = useTranslation("menu");
|
const { t } = useTranslation("menu");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,32 +56,27 @@ export default memo(function SiteMenu({
|
|||||||
{
|
{
|
||||||
titleKey: "community",
|
titleKey: "community",
|
||||||
items: [
|
items: [
|
||||||
{ labelKey: "dating", href: "/dating", icon: "❤️" },
|
|
||||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||||
{ labelKey: "hostMeetup", href: "/meetups", icon: "🎤", new: true },
|
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||||
{ labelKey: "friendFinder", href: "/dating", icon: "💛" },
|
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||||
{ labelKey: "membersMap", href: "#", icon: "🌐" },
|
|
||||||
{ labelKey: "attendMeetup", href: "/meetups", icon: "🍹" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: "tools",
|
titleKey: "tools",
|
||||||
items: [
|
items: [
|
||||||
{ labelKey: "explore", href: "/", icon: "📍" },
|
|
||||||
{ labelKey: "nextStop", href: "/dashboard", icon: "🗺️" },
|
|
||||||
{ labelKey: "climateFinder", href: "/tools", icon: "🌤️" },
|
{ labelKey: "climateFinder", href: "/tools", icon: "🌤️" },
|
||||||
{ labelKey: "fireCalculator", href: "/tools", icon: "🌱" },
|
{ labelKey: "fireCalculator", href: "/tools", icon: "🌱" },
|
||||||
|
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
||||||
{ labelKey: "aiAssistant", href: "/ai", icon: "🤖", new: true },
|
{ labelKey: "aiAssistant", href: "/ai", icon: "🤖", new: true },
|
||||||
{ labelKey: "yearInReview", href: "/report", icon: "🥂" },
|
{ 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: "gigBoard", href: "/gigs", icon: "💰" },
|
||||||
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: "help",
|
titleKey: "help",
|
||||||
items: [
|
items: [
|
||||||
{ labelKey: "ideasBugs", href: "#", icon: "💡" },
|
{ labelKey: "ideasBugs", href: "/feedback", icon: "💡" },
|
||||||
{ labelKey: "faq", href: "#", icon: "🆘" },
|
{ labelKey: "faq", href: "#", icon: "🆘" },
|
||||||
{ labelKey: "terms", href: "#", icon: "📄" },
|
{ labelKey: "terms", href: "#", icon: "📄" },
|
||||||
{ labelKey: "changelog", href: "#", icon: "🚀" },
|
{ labelKey: "changelog", href: "#", icon: "🚀" },
|
||||||
@@ -106,14 +99,7 @@ export default memo(function SiteMenu({
|
|||||||
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
|
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredSections = sections.map((sec) => ({
|
const filteredSections = sections;
|
||||||
...sec,
|
|
||||||
items: search
|
|
||||||
? sec.items.filter((i) =>
|
|
||||||
t(i.labelKey).toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: sec.items,
|
|
||||||
})).filter((sec) => sec.items.length > 0);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -124,59 +110,7 @@ export default memo(function SiteMenu({
|
|||||||
ref={ref}
|
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"
|
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 */}
|
{/* Profile links */}
|
||||||
<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 */}
|
|
||||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 flex flex-wrap items-center gap-2">
|
<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) => (
|
{profileItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -189,10 +123,6 @@ export default memo(function SiteMenu({
|
|||||||
<span>{t(item.labelKey)}</span>
|
<span>{t(item.labelKey)}</span>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sections */}
|
{/* Sections */}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useTheme } from "@/app/context/ThemeContext";
|
import { useTheme } from "@/app/context/ThemeContext";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { t } = useTranslation("common");
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ export default function ThemeToggle() {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<div className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -29,8 +31,8 @@ export default function ThemeToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
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"
|
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 ? "切换到日间模式" : "切换到夜间模式"}
|
aria-label={isDark ? t("themeDark") : t("themeLight")}
|
||||||
title={isDark ? "日间模式" : "夜间模式"}
|
title={isDark ? t("themeDark") : t("themeLight")}
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{isDark ? (
|
||||||
<svg
|
<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 {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -40,6 +41,37 @@ html {
|
|||||||
background: #d1d5db;
|
background: #d1d5db;
|
||||||
border-radius: 3px;
|
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 (mobile-first) ==================== */
|
||||||
.items-grid {
|
.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",
|
"logout": "Logout",
|
||||||
"loginRegister": "Login / Sign up",
|
"loginRegister": "Login / Sign up",
|
||||||
"explore": "Explore →",
|
"explore": "Explore →",
|
||||||
"toggleMenu": "Toggle menu"
|
"toggleMenu": "Toggle menu",
|
||||||
|
"themeLight": "Switch to dark mode",
|
||||||
|
"themeDark": "Switch to light mode"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"searchPlaceholder": "Search or filter",
|
"searchPlaceholder": "Search or filter",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"yourProfile": "Your profile",
|
"yourProfile": "Your profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
|
"pricing": "Pricing",
|
||||||
"nomadInsurance": "Nomad Insurance",
|
"nomadInsurance": "Nomad Insurance",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"cities": "Cities",
|
"cities": "Cities",
|
||||||
"menu": "Menu",
|
"menu": "More",
|
||||||
"ai": "AI",
|
"ai": "AI",
|
||||||
"travel": "Travel",
|
"travel": "Travel",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
@@ -70,6 +73,20 @@
|
|||||||
"feature4": "Gig board: post/claim tasks, platform fee 10-15%",
|
"feature4": "Gig board: post/claim tasks, platform fee 10-15%",
|
||||||
"feature5": "Nomad report: countries visited? Generate share card",
|
"feature5": "Nomad report: countries visited? Generate share card",
|
||||||
"ctaJoin": "Join Nomad China →",
|
"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": {
|
"stats": {
|
||||||
"cities": "Cities",
|
"cities": "Cities",
|
||||||
"nomads": "Nomads",
|
"nomads": "Nomads",
|
||||||
@@ -77,6 +94,37 @@
|
|||||||
"cost": "Monthly cost from"
|
"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": {
|
"home": {
|
||||||
"stats": {
|
"stats": {
|
||||||
"cities": "Cities",
|
"cities": "Cities",
|
||||||
@@ -114,7 +162,11 @@
|
|||||||
"cityGuides": "City Guides",
|
"cityGuides": "City Guides",
|
||||||
"viewGuides": "View guides →",
|
"viewGuides": "View guides →",
|
||||||
"noMatch": "No matching cities found",
|
"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": {
|
"footer": {
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
@@ -165,6 +217,10 @@
|
|||||||
},
|
},
|
||||||
"copyright": "© 2024-2026 Nomad China. All rights reserved."
|
"copyright": "© 2024-2026 Nomad China. All rights reserved."
|
||||||
},
|
},
|
||||||
|
"cityCard": {
|
||||||
|
"nomadsHere": "nomads here",
|
||||||
|
"perMonth": "mo"
|
||||||
|
},
|
||||||
"cityModal": {
|
"cityModal": {
|
||||||
"breadcrumbPrefix": "Cost of Living",
|
"breadcrumbPrefix": "Cost of Living",
|
||||||
"reviews": "reviews",
|
"reviews": "reviews",
|
||||||
@@ -230,6 +286,24 @@
|
|||||||
"region": "Asia",
|
"region": "Asia",
|
||||||
"map": "Map"
|
"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": {
|
"auth": {
|
||||||
"title": "Login / Sign up",
|
"title": "Login / Sign up",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -347,8 +421,19 @@
|
|||||||
"examples": "Example questions",
|
"examples": "Example questions",
|
||||||
"comingSoon": "AI feature coming soon"
|
"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": {
|
"report": {
|
||||||
"title": "Nomad Year Report",
|
"title": "Nomad Year Report",
|
||||||
|
"dataReport": "Data Report",
|
||||||
"desc": "Auto stats: countries visited, avg monthly cost, coworking check-ins",
|
"desc": "Auto stats: countries visited, avg monthly cost, coworking check-ins",
|
||||||
"yearTitle": "2025 Nomad Report",
|
"yearTitle": "2025 Nomad Report",
|
||||||
"countries": "Countries",
|
"countries": "Countries",
|
||||||
@@ -379,6 +464,44 @@
|
|||||||
"notFound": "City guide not found",
|
"notFound": "City guide not found",
|
||||||
"backToHome": "Back to Home"
|
"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": {
|
"services": {
|
||||||
"title": "Services",
|
"title": "Services",
|
||||||
"subtitle": "Premium services for digital nomads",
|
"subtitle": "Premium services for digital nomads",
|
||||||
|
|||||||
129
messages/zh.json
129
messages/zh.json
@@ -7,7 +7,9 @@
|
|||||||
"logout": "退出",
|
"logout": "退出",
|
||||||
"loginRegister": "登录 / 注册",
|
"loginRegister": "登录 / 注册",
|
||||||
"explore": "开始探索 →",
|
"explore": "开始探索 →",
|
||||||
"toggleMenu": "切换菜单"
|
"toggleMenu": "切换菜单",
|
||||||
|
"themeLight": "切换到夜间模式",
|
||||||
|
"themeDark": "切换到日间模式"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"searchPlaceholder": "搜索或筛选",
|
"searchPlaceholder": "搜索或筛选",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"yourProfile": "个人资料",
|
"yourProfile": "个人资料",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
|
"pricing": "会员",
|
||||||
"nomadInsurance": "游民保险",
|
"nomadInsurance": "游民保险",
|
||||||
"community": "社区",
|
"community": "社区",
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"cities": "城市",
|
"cities": "城市",
|
||||||
"menu": "功能菜单",
|
"menu": "更多",
|
||||||
"ai": "AI",
|
"ai": "AI",
|
||||||
"travel": "旅行",
|
"travel": "旅行",
|
||||||
"community": "社区",
|
"community": "社区",
|
||||||
@@ -70,6 +73,20 @@
|
|||||||
"feature4": "赏金墙:发布/接单小任务,平台抽成 10-15%",
|
"feature4": "赏金墙:发布/接单小任务,平台抽成 10-15%",
|
||||||
"feature5": "Nomad 年报:今年飞了多少国家?生成分享卡片",
|
"feature5": "Nomad 年报:今年飞了多少国家?生成分享卡片",
|
||||||
"ctaJoin": "加入游牧中国 →",
|
"ctaJoin": "加入游牧中国 →",
|
||||||
|
"emailPlaceholder": "输入你的邮箱...",
|
||||||
|
"loginHint": "已有账号?输入邮箱即可自动登录",
|
||||||
|
"membersJoined": "5,000+ 成员已加入",
|
||||||
|
"mediaPress": "媒体报道",
|
||||||
|
"quickLinks": {
|
||||||
|
"nextStop": "下一站",
|
||||||
|
"meetups": "线下聚会",
|
||||||
|
"dating": "智能匹配",
|
||||||
|
"tools": "工具箱",
|
||||||
|
"ai": "AI 助理",
|
||||||
|
"gigs": "赏金墙",
|
||||||
|
"report": "年报",
|
||||||
|
"join": "加入社群"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"cities": "个城市",
|
"cities": "个城市",
|
||||||
"nomads": "位游民",
|
"nomads": "位游民",
|
||||||
@@ -77,6 +94,37 @@
|
|||||||
"cost": "起/月生活费"
|
"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": {
|
"home": {
|
||||||
"stats": {
|
"stats": {
|
||||||
"cities": "个城市",
|
"cities": "个城市",
|
||||||
@@ -114,7 +162,11 @@
|
|||||||
"cityGuides": "城市攻略",
|
"cityGuides": "城市攻略",
|
||||||
"viewGuides": "查看攻略 →",
|
"viewGuides": "查看攻略 →",
|
||||||
"noMatch": "没有找到匹配的城市",
|
"noMatch": "没有找到匹配的城市",
|
||||||
"showAll": "显示全部城市"
|
"showAll": "显示全部城市",
|
||||||
|
"meetupDate": "3月15日",
|
||||||
|
"meetupLocation": "深圳南山",
|
||||||
|
"meetupPeople": "12人",
|
||||||
|
"joinedThisMonthShort": "本月 86 人加入"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"community": "社区",
|
"community": "社区",
|
||||||
@@ -165,6 +217,10 @@
|
|||||||
},
|
},
|
||||||
"copyright": "© 2024-2026 游牧中国 NomadCNA. All rights reserved."
|
"copyright": "© 2024-2026 游牧中国 NomadCNA. All rights reserved."
|
||||||
},
|
},
|
||||||
|
"cityCard": {
|
||||||
|
"nomadsHere": "人在此",
|
||||||
|
"perMonth": "月"
|
||||||
|
},
|
||||||
"cityModal": {
|
"cityModal": {
|
||||||
"breadcrumbPrefix": "生活成本",
|
"breadcrumbPrefix": "生活成本",
|
||||||
"reviews": "条评价",
|
"reviews": "条评价",
|
||||||
@@ -230,6 +286,24 @@
|
|||||||
"region": "亚洲",
|
"region": "亚洲",
|
||||||
"map": "地图"
|
"map": "地图"
|
||||||
},
|
},
|
||||||
|
"feedback": {
|
||||||
|
"title": "建议与反馈",
|
||||||
|
"subtitle": "帮助我们改进产品,你的声音很重要",
|
||||||
|
"type": "类型",
|
||||||
|
"suggestion": "建议",
|
||||||
|
"bug": "Bug 反馈",
|
||||||
|
"email": "邮箱",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"titleLabel": "标题",
|
||||||
|
"titlePlaceholder": "简要描述问题或建议",
|
||||||
|
"content": "详细内容",
|
||||||
|
"contentPlaceholder": "请详细描述你的想法或遇到的问题...",
|
||||||
|
"submit": "提交",
|
||||||
|
"demoHint": "当前为演示模式,接入后端后将正式发送。",
|
||||||
|
"successTitle": "感谢你的反馈!",
|
||||||
|
"successDesc": "我们已收到你的提交,会尽快处理。",
|
||||||
|
"submitAnother": "再提交一条"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "登录 / 注册",
|
"title": "登录 / 注册",
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
@@ -347,8 +421,19 @@
|
|||||||
"examples": "示例问题",
|
"examples": "示例问题",
|
||||||
"comingSoon": "AI 功能即将上线,敬请期待"
|
"comingSoon": "AI 功能即将上线,敬请期待"
|
||||||
},
|
},
|
||||||
|
"dataReport": {
|
||||||
|
"title": "数据报告",
|
||||||
|
"subtitle": "成员分布、城市排名、增长趋势",
|
||||||
|
"backToReport": "返回年报",
|
||||||
|
"membersMap": "成员分布地图",
|
||||||
|
"cityRanking": "城市成员排名 Top 10",
|
||||||
|
"regionDist": "区域分布",
|
||||||
|
"growthTrend": "增长趋势",
|
||||||
|
"demoHint": "演示数据,接入数据库后将展示真实统计"
|
||||||
|
},
|
||||||
"report": {
|
"report": {
|
||||||
"title": "Nomad 年报",
|
"title": "Nomad 年报",
|
||||||
|
"dataReport": "数据报告",
|
||||||
"desc": "自动统计:今年飞了多少国家、平均月生活成本、在多少个 co-working 打卡",
|
"desc": "自动统计:今年飞了多少国家、平均月生活成本、在多少个 co-working 打卡",
|
||||||
"yearTitle": "2025 Nomad 年报",
|
"yearTitle": "2025 Nomad 年报",
|
||||||
"countries": "国家/地区",
|
"countries": "国家/地区",
|
||||||
@@ -379,6 +464,44 @@
|
|||||||
"notFound": "城市指南不存在",
|
"notFound": "城市指南不存在",
|
||||||
"backToHome": "返回首页"
|
"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": {
|
"services": {
|
||||||
"title": "增值服务",
|
"title": "增值服务",
|
||||||
"subtitle": "数字游民专属增值服务",
|
"subtitle": "数字游民专属增值服务",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
minio:
|
minio:
|
||||||
specifier: ^8.0.7
|
specifier: ^8.0.7
|
||||||
version: 8.0.7
|
version: 8.0.7
|
||||||
@@ -903,6 +906,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.307:
|
electron-to-chromium@1.5.307:
|
||||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||||
|
|
||||||
@@ -1924,6 +1930,9 @@ packages:
|
|||||||
tsconfig-paths@3.15.0:
|
tsconfig-paths@3.15.0:
|
||||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2030,6 +2039,9 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -2861,6 +2873,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.307: {}
|
electron-to-chromium@1.5.307: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
@@ -4085,6 +4102,8 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-bom: 3.0.0
|
strip-bom: 3.0.0
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
@@ -4245,3 +4264,7 @@ snapshots:
|
|||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user