This commit is contained in:
eric
2026-03-09 05:07:59 -05:00
parent 302a072036
commit eb852fb11d
27 changed files with 1523 additions and 169 deletions

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

View File

@@ -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
View 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>
);
}

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

View File

@@ -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>

View File

@@ -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">
🗓 315 🗓 {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) => (

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

View File

@@ -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 ? (

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View 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;
}

View File

@@ -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 */}

View File

@@ -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
View 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 },
};

View File

@@ -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 {

View 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
View 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`: 饼图数据项

View 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
View 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
View 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[];
}

View File

@@ -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",

View File

@@ -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": "数字游民专属增值服务",

View File

@@ -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
View File

@@ -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