diff --git a/.gitignore b/.gitignore
index 2f23f3c..bd44de7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
# .env*
+cursor.env
# vercel
.vercel
diff --git a/app/[locale]/dating/components/LikesPanel.tsx b/app/[locale]/dating/components/LikesPanel.tsx
index cd8d758..3203073 100644
--- a/app/[locale]/dating/components/LikesPanel.tsx
+++ b/app/[locale]/dating/components/LikesPanel.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { mediaUrl } from "@/app/lib/media";
import type { DatingProfile } from "../types";
@@ -10,6 +11,8 @@ interface LikesPanelProps {
formatTime: (value?: string) => string;
t: (key: string) => string;
onSelectProfile?: (profile: DatingProfile) => void;
+ onLikeBack?: (profile: DatingProfile) => void;
+ likingBackId?: string | null;
}
function ProfileRow({
@@ -17,11 +20,13 @@ function ProfileRow({
timeLabel,
badge,
onSelect,
+ action,
}: {
profile: DatingProfile;
timeLabel: string;
badge?: string;
onSelect?: () => void;
+ action?: ReactNode;
}) {
const className =
"flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900";
@@ -41,9 +46,12 @@ function ProfileRow({
) : null}
-
- {timeLabel}
-
+
+ {action}
+
+ {timeLabel}
+
+
>
);
if (onSelect) {
@@ -56,7 +64,15 @@ function ProfileRow({
return {content}
;
}
-export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
+export default function LikesPanel({
+ likes,
+ mutual,
+ formatTime,
+ t,
+ onSelectProfile,
+ onLikeBack,
+ likingBackId,
+}: LikesPanelProps) {
return (
@@ -77,6 +93,21 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
profile={profile}
timeLabel={formatTime(profile.likedAt)}
onSelect={() => onSelectProfile?.(profile)}
+ action={
+ onLikeBack ? (
+
+ ) : null
+ }
/>
))
) : (
diff --git a/app/[locale]/dating/components/SwipeCard.tsx b/app/[locale]/dating/components/SwipeCard.tsx
index 66c7fea..486330b 100644
--- a/app/[locale]/dating/components/SwipeCard.tsx
+++ b/app/[locale]/dating/components/SwipeCard.tsx
@@ -12,6 +12,8 @@ interface SwipeCardProps {
isAnimatingOut: "left" | "right" | null;
onLike: () => void;
onDislike: () => void;
+ onSuperLike?: () => void;
+ showSuperLike?: boolean;
onBeginDrag: (clientX: number) => void;
onMoveDrag: (clientX: number) => void;
onEndDrag: () => void;
@@ -26,6 +28,8 @@ export default function SwipeCard({
isAnimatingOut,
onLike,
onDislike,
+ onSuperLike,
+ showSuperLike = false,
onBeginDrag,
onMoveDrag,
onEndDrag,
@@ -76,17 +80,20 @@ export default function SwipeCard({
e.stopPropagation()}
>
{t("report")}
-
})
+
e.stopPropagation()}>
+
})
+
@@ -114,11 +121,17 @@ export default function SwipeCard({
{profile.bio}
-
+
e.stopPropagation()}
+ >
{t("dislike")}
+ {showSuperLike && onSuperLike ? (
+
+
+ {t("superlike")}
+
+ ) : null}
diff --git a/app/[locale]/dating/utils.ts b/app/[locale]/dating/utils.ts
index 3a6e83f..ac4d438 100644
--- a/app/[locale]/dating/utils.ts
+++ b/app/[locale]/dating/utils.ts
@@ -45,10 +45,20 @@ export function filterFallbackProfiles(
) {
return false;
}
- if (advanced?.gender && advanced.gender !== "all" && profile.gender !== advanced.gender) {
+ if (
+ advanced?.gender &&
+ advanced.gender !== "all" &&
+ advanced.gender !== "不限" &&
+ profile.gender !== advanced.gender
+ ) {
return false;
}
- if (advanced?.single && advanced.single !== "all" && profile.single !== advanced.single) {
+ if (
+ advanced?.single &&
+ advanced.single !== "all" &&
+ advanced.single !== "不限" &&
+ profile.single !== advanced.single
+ ) {
return false;
}
return true;
@@ -123,7 +133,7 @@ export async function fetchMutualMatches(): Promise
{
export async function saveSwipe(
profileId: string,
- action: "like" | "dislike",
+ action: "like" | "dislike" | "superlike",
intent: MatchIntent
): Promise {
return apiFetch("/api/matches/swipes", {
diff --git a/app/[locale]/discuss/[id]/page.tsx b/app/[locale]/discuss/[id]/page.tsx
new file mode 100644
index 0000000..48c5e6f
--- /dev/null
+++ b/app/[locale]/discuss/[id]/page.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { FormEvent, useEffect, useState } from "react";
+import { Link, useLocale } from "@/i18n/navigation";
+import { useParams } from "next/navigation";
+import Footer from "@/app/components/Footer";
+import { fetchAuthMe } from "@/app/lib/api-client";
+import {
+ fetchDiscussion,
+ fetchDiscussionReplies,
+ postDiscussionReply,
+ toggleDiscussionLike,
+ toggleDiscussionPin,
+ type DiscussionReply,
+} from "@/app/lib/social";
+
+export default function DiscussThreadPage() {
+ const params = useParams();
+ const discussionId = String(params?.id || "");
+ const locale = useLocale();
+ const [topic, setTopic] = useState | null>(null);
+ const [replies, setReplies] = useState([]);
+ const [draft, setDraft] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [posting, setPosting] = useState(false);
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [currentUserId, setCurrentUserId] = useState("");
+ const [liking, setLiking] = useState(false);
+ const [pinning, setPinning] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ fetchAuthMe().then((data) => {
+ const userId = String(data?.user?.id || "");
+ setIsLoggedIn(Boolean(userId));
+ setCurrentUserId(userId);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!discussionId) return;
+ let cancelled = false;
+ Promise.all([fetchDiscussion(discussionId), fetchDiscussionReplies(discussionId)])
+ .then(([topicData, replyItems]) => {
+ if (cancelled) return;
+ setTopic(topicData.item);
+ setReplies(replyItems);
+ })
+ .catch(() => {
+ if (!cancelled) setError(locale === "zh" ? "加载失败" : "Failed to load");
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [discussionId, locale]);
+
+ const handleLike = async () => {
+ if (!isLoggedIn || !discussionId) return;
+ setLiking(true);
+ setError("");
+ try {
+ const result = await toggleDiscussionLike(discussionId);
+ setTopic((current) =>
+ current
+ ? {
+ ...current,
+ likes: result.likes,
+ likedByMe: result.liked,
+ }
+ : current
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : locale === "zh" ? "点赞失败" : "Like failed");
+ } finally {
+ setLiking(false);
+ }
+ };
+
+ const handlePin = async () => {
+ if (!isLoggedIn || !discussionId) return;
+ setPinning(true);
+ setError("");
+ try {
+ const result = await toggleDiscussionPin(discussionId);
+ setTopic((current) =>
+ current
+ ? {
+ ...current,
+ pinned: result.pinned,
+ }
+ : current
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : locale === "zh" ? "置顶失败" : "Pin failed");
+ } finally {
+ setPinning(false);
+ }
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ const body = draft.trim();
+ if (!body || !discussionId) return;
+ if (!isLoggedIn) {
+ setError(locale === "zh" ? "请先登录后再回复" : "Please log in to reply");
+ return;
+ }
+ setPosting(true);
+ setError("");
+ try {
+ const reply = await postDiscussionReply(discussionId, body);
+ setReplies((current) => [...current, reply]);
+ setDraft("");
+ setTopic((current) =>
+ current ? { ...current, replies: Number(current.replies || 0) + 1 } : current
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : locale === "zh" ? "回复失败" : "Reply failed");
+ } finally {
+ setPosting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!topic) {
+ return (
+
+
+
{error || (locale === "zh" ? "话题不存在" : "Topic not found")}
+
+ {locale === "zh" ? "返回讨论区" : "Back to discussions"}
+
+
+
+ );
+ }
+
+ const tags = Array.isArray(topic.tags) ? topic.tags.map(String) : [];
+ const likedByMe = Boolean(topic.likedByMe);
+ const likes = Number(topic.likes || 0);
+ const pinned = Boolean(topic.pinned);
+ const isAuthor = Boolean(currentUserId && String(topic.authorUserId || "") === currentUserId);
+
+ return (
+
+
+
+ ← {locale === "zh" ? "返回讨论区" : "Back"}
+
+
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
{String(topic.title || "")}
+ {pinned ? (
+
+ 📌 {locale === "zh" ? "置顶" : "Pinned"}
+
+ ) : null}
+
+
+ {String(topic.author || "社区成员")}
+ {topic.city ? ` · ${String(topic.city)}` : ""}
+ {` · 👁 ${Number(topic.views || 0)} · 💬 ${Number(topic.replies || 0)}`}
+
+
+
+ {isAuthor ? (
+
+ ) : null}
+
+ {topic.excerpt ? (
+
+ {String(topic.excerpt)}
+
+ ) : null}
+
+
+
+
+ {locale === "zh" ? "回复" : "Replies"} ({replies.length})
+
+ {replies.length ? (
+ replies.map((reply) => (
+
+
+
+ {reply.author || (locale === "zh" ? "成员" : "Member")}
+
+ {reply.created ? new Date(reply.created).toLocaleString() : ""}
+
+
{reply.body}
+
+ ))
+ ) : (
+
+ {locale === "zh" ? "还没有回复,来抢沙发吧" : "No replies yet"}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/[locale]/discuss/page.tsx b/app/[locale]/discuss/page.tsx
index f5c0e83..6ee3566 100644
--- a/app/[locale]/discuss/page.tsx
+++ b/app/[locale]/discuss/page.tsx
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import { useTranslation } from "@/i18n/navigation";
+import { Link, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
-import { apiFetch } from "@/app/lib/api-client";
+import { apiFetch, fetchAuthMe } from "@/app/lib/api-client";
+import { toggleDiscussionLike } from "@/app/lib/social";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex, avatarForName } from "@/app/data/avatars";
@@ -19,6 +20,7 @@ interface Topic {
lastActive: string;
pinned: boolean;
excerpt?: string;
+ likedByMe?: boolean;
}
interface DiscussionRecord {
@@ -30,6 +32,9 @@ interface DiscussionRecord {
excerpt?: string;
replies?: number;
views?: number;
+ likes?: number;
+ pinned?: boolean;
+ likedByMe?: boolean;
created?: string;
}
@@ -143,7 +148,7 @@ const CATEGORIES = [
{ key: "目的地", zh: "目的地", en: "Destinations" },
];
-function mapDiscussion(topic: DiscussionRecord, index: number): Topic {
+function mapDiscussion(topic: DiscussionRecord): Topic {
const author = topic.author || "NomadCNA";
const tags = Array.isArray(topic.tags) ? topic.tags : [];
return {
@@ -153,11 +158,12 @@ function mapDiscussion(topic: DiscussionRecord, index: number): Topic {
avatar: avatarForName(author),
views: Number(topic.views || 0),
replies: Number(topic.replies || 0),
- likes: Math.max(3, Math.round(Number(topic.views || 0) / 30) + Number(topic.replies || 0)),
+ likes: Number(topic.likes || 0),
category: tags[0] || topic.city || "资源分享",
lastActive: topic.created ? new Date(topic.created).toLocaleDateString("zh-CN") : "刚刚",
- pinned: index < 2,
+ pinned: Boolean(topic.pinned),
excerpt: topic.excerpt,
+ likedByMe: Boolean(topic.likedByMe),
};
}
@@ -169,6 +175,12 @@ export default function DiscussPage() {
const [showComposer, setShowComposer] = useState(false);
const [posting, setPosting] = useState(false);
const [draft, setDraft] = useState({ title: "", city: "", excerpt: "" });
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [likingId, setLikingId] = useState(null);
+
+ useEffect(() => {
+ fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
+ }, []);
useEffect(() => {
apiFetch<{ items: DiscussionRecord[] }>("/api/discussions")
@@ -180,6 +192,25 @@ export default function DiscussPage() {
.catch(() => setTopics(FALLBACK_TOPICS));
}, []);
+ const handleLike = async (topicId: string, event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!isLoggedIn) return;
+ setLikingId(topicId);
+ try {
+ const result = await toggleDiscussionLike(topicId);
+ setTopics((current) =>
+ current.map((topic) =>
+ topic.id === topicId
+ ? { ...topic, likes: result.likes, likedByMe: result.liked }
+ : topic
+ )
+ );
+ } finally {
+ setLikingId(null);
+ }
+ };
+
const filteredTopics = useMemo(() => topics.filter((topic) => {
const matchesCategory = activeCategory === "all" || topic.category === activeCategory;
const matchesSearch = topic.title.toLowerCase().includes(searchQuery.toLowerCase());
@@ -201,7 +232,7 @@ export default function DiscussPage() {
excerpt: draft.excerpt.trim(),
}),
});
- setTopics((current) => [mapDiscussion(data.record, 0), ...current]);
+ setTopics((current) => [mapDiscussion(data.record), ...current]);
setDraft({ title: "", city: "", excerpt: "" });
setShowComposer(false);
} finally {
@@ -292,9 +323,10 @@ export default function DiscussPage() {
{filteredTopics.map((topic) => (
-
![]()
·
👁 {topic.views}
💬 {topic.replies}
-
❤️ {topic.likes}
+
-
+
))}
diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx
index e7f5e08..772d5ee 100644
--- a/app/[locale]/join/page.tsx
+++ b/app/[locale]/join/page.tsx
@@ -73,21 +73,50 @@ export default function JoinPage() {
const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false);
useEffect(() => {
- if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
- setSubmitted(true);
- return;
- }
const checkExisting = async () => {
try {
const meData = await fetchAuthMe();
if (!meData?.user) return;
- const statusData = await apiFetch<{ hasRecord?: boolean; isComplete?: boolean }>(
- "/api/join/status",
- { cache: "no-store" }
- );
+ const [statusData, profileData] = await Promise.all([
+ apiFetch<{ hasRecord?: boolean; isComplete?: boolean }>("/api/join/status", {
+ cache: "no-store",
+ }),
+ apiFetch<{
+ profile?: {
+ name?: string;
+ gender?: string;
+ single?: string;
+ location?: string;
+ citySlug?: string;
+ bio?: string;
+ photo?: string;
+ lookingFor?: string[];
+ tags?: string[];
+ };
+ }>("/api/matches/profile/me", { cache: "no-store" }),
+ ]);
+ const profile = profileData?.profile;
+ if (profile) {
+ setForm((prev) => ({
+ ...prev,
+ nickname: profile.name || prev.nickname,
+ gender: profile.gender || prev.gender,
+ single: profile.single || prev.single,
+ location: profile.location || prev.location,
+ citySlug: profile.citySlug || prev.citySlug,
+ intro: profile.bio || prev.intro,
+ profession: profile.tags?.[0] || prev.profession,
+ education: profile.tags?.[1] || prev.education,
+ }));
+ if (Array.isArray(profile.lookingFor) && profile.lookingFor.length > 0) {
+ setLookingFor(profile.lookingFor);
+ }
+ if (profile.photo) {
+ setMedia({ preview: profile.photo, url: profile.photo, type: "image" });
+ }
+ }
if (statusData?.hasRecord && statusData?.isComplete) {
setIsAlreadyRecorded(true);
- setSubmitted(true);
}
} catch {
// Keep the form usable when the API proxy/backend is temporarily unavailable.
@@ -219,6 +248,12 @@ export default function JoinPage() {
+ {isAlreadyRecorded && (
+
+ 你已提交过资料,可直接修改后保存更新,匹配与活动推荐会同步使用最新信息。
+
+ )}
+
🌍 数字游民社区报名
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 9f9e3de..a6b2d94 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -5,6 +5,7 @@ import type { Locale } from "../context/LocaleContext";
import { getThemeMessages } from "@/app/lib/theme-data";
import RouteAwareNavbar from "../components/RouteAwareNavbar";
import SetLangAttr from "../components/SetLangAttr";
+import DevDebugTools from "../components/DevDebugTools";
const LOCALES: Locale[] = ["zh", "en"];
@@ -34,9 +35,11 @@ export default async function LocaleLayout({
return (
-
-
- {children}
+
+
+
+ {children}
+
);
}
diff --git a/app/[locale]/meetups/host/page.tsx b/app/[locale]/meetups/host/page.tsx
index 48b60fa..c696012 100644
--- a/app/[locale]/meetups/host/page.tsx
+++ b/app/[locale]/meetups/host/page.tsx
@@ -99,6 +99,7 @@ export default function HostMeetupPage() {
const locale = useLocale();
const c = getCopy(locale);
const [submitted, setSubmitted] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
mode: "offline",
@@ -152,10 +153,12 @@ export default function HostMeetupPage() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSubmitting(true);
+ setSubmitError(null);
try {
const finalCity = isOnlineOnly && !form.city ? "线上" : form.city;
const finalVenue = isOnlineOnly && !form.venue ? "MiroTalk 多人视频房" : form.venue;
const finalAddress = isOnlineOnly && !form.address ? "线上活动" : form.address;
+ const customRoom = normalizeMiroTalkRoom(form.mirotalkRoom);
await apiFetch("/api/meetups", {
method: "POST",
body: JSON.stringify({
@@ -166,7 +169,6 @@ export default function HostMeetupPage() {
venue: finalVenue,
address: finalAddress,
description: form.description,
- rsvpCount: 1,
gradientFrom: city?.gradientFrom || (isOnlineMode ? "#0f766e" : "#ff4d4f"),
gradientTo: city?.gradientTo || (isOnlineMode ? "#2563eb" : "#ff7a45"),
attendees: [],
@@ -177,13 +179,21 @@ export default function HostMeetupPage() {
organizer: form.organizer.trim() || "NomadCNA",
accessLevel: form.accessLevel,
meetingProvider: isOnlineMode ? form.meetingProvider : "",
- mirotalkRoom: isOnlineMode ? roomToUse : "",
+ mirotalkRoom: isOnlineMode && customRoom ? customRoom : "",
meetingUrl: isOnlineMode ? form.meetingUrl.trim() : "",
replayUrl: "",
features: isOnlineMode ? form.features : [],
}),
});
setSubmitted(true);
+ } catch (err) {
+ setSubmitError(
+ err instanceof Error
+ ? err.message
+ : locale === "zh"
+ ? "提交失败,请稍后重试"
+ : "Submission failed, please try again"
+ );
} finally {
setSubmitting(false);
}
@@ -479,6 +489,12 @@ export default function HostMeetupPage() {
{c.pendingHint}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+ );
+ if (attendee.profileId) {
+ return (
+
+ {image}
+
+ );
+ }
+ return image;
+}
interface Attendee {
name: string;
photo: string;
+ profileId?: string;
+ userId?: string;
}
interface Meetup {
@@ -467,6 +490,15 @@ function toAccessLevel(value: unknown): MeetupAccessLevel {
return value === "members" || value === "vip" || value === "hosts" || value === "public" ? value : "public";
}
+function computeIsUpcoming(item: { date?: string; time?: string; isUpcoming?: boolean }): boolean {
+ const datePart = String(item.date || "").slice(0, 10);
+ if (!datePart) return Boolean(item.isUpcoming ?? true);
+ const timePart = String(item.time || "23:59").slice(0, 5);
+ const eventAt = new Date(`${datePart}T${timePart}:00`);
+ if (Number.isNaN(eventAt.getTime())) return Boolean(item.isUpcoming ?? true);
+ return eventAt.getTime() > Date.now();
+}
+
function mapMeetup(item: Partial & { id: string; status?: string }): Meetup {
const mode = toMeetupMode(item.mode || (item.meetingUrl || item.mirotalkRoom ? "online" : "offline"));
return {
@@ -482,7 +514,7 @@ function mapMeetup(item: Partial & { id: string; status?: string }): Mee
gradientFrom: item.gradientFrom || (mode === "online" ? "#0f766e" : "#ff4d4f"),
gradientTo: item.gradientTo || (mode === "online" ? "#2563eb" : "#ff7a45"),
attendees: Array.isArray(item.attendees) ? item.attendees : [],
- isUpcoming: Boolean(item.isUpcoming ?? true),
+ isUpcoming: computeIsUpcoming(item),
mode,
accessLevel: toAccessLevel(item.accessLevel),
meetingProvider: item.meetingProvider || (mode === "offline" ? "" : "mirotalk"),
@@ -588,7 +620,11 @@ function MeetupCard({ meetup, locale, viewer, onSelect }: { meetup: Meetup; loca
{meetup.attendees.map((a, index) => (
-
})
+
))}
{locale === "zh" ? "已报名成员" : "attendees"}
@@ -598,17 +634,66 @@ function MeetupCard({ meetup, locale, viewer, onSelect }: { meetup: Meetup; loca
);
}
-function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup; locale: string; viewer: ViewerState; onClose: () => void }) {
+function MeetupDetailModal({
+ meetup,
+ locale,
+ viewer,
+ onClose,
+ onMeetupUpdated,
+}: {
+ meetup: Meetup;
+ locale: string;
+ viewer: ViewerState;
+ onClose: () => void;
+ onMeetupUpdated?: (meetup: Meetup) => void;
+}) {
const c = getCopy(locale);
const [embedOpen, setEmbedOpen] = useState(false);
- const canJoin = canAccessMeetup(meetup, viewer);
- const joinUrl = buildMiroTalkJoinUrl(meetup, viewerDisplayName(viewer));
- const chatUrl = buildMeetupChatUrl(meetup);
- const livePath = buildMeetupLivePath(meetup.id);
+ const [rsvped, setRsvped] = useState(false);
+ const [rsvpLoading, setRsvpLoading] = useState(false);
+ const [localMeetup, setLocalMeetup] = useState(meetup);
+
+ useEffect(() => {
+ setLocalMeetup(meetup);
+ }, [meetup]);
+
+ useEffect(() => {
+ if (!viewer.user?.id) {
+ setRsvped(false);
+ return;
+ }
+ fetchMeetupRsvpStatus(meetup.id).then(setRsvped);
+ }, [meetup.id, viewer.user?.id]);
+ const canJoin = canAccessMeetup(localMeetup, viewer);
+ const joinUrl = buildMiroTalkJoinUrl(localMeetup, viewerDisplayName(viewer));
+ const chatUrl = buildMeetupChatUrl(localMeetup);
+ const livePath = buildMeetupLivePath(localMeetup.id);
+
+ const applyMeetupUpdate = (raw: Record
) => {
+ const next = mapMeetup({ ...raw, id: String(raw.id || localMeetup.id) } as Partial & { id: string });
+ setLocalMeetup(next);
+ onMeetupUpdated?.(next);
+ };
+
+ const handleRsvp = async () => {
+ if (!viewer.user?.id) return;
+ setRsvpLoading(true);
+ try {
+ if (rsvped) {
+ const data = await cancelMeetupRsvp(localMeetup.id);
+ setRsvped(false);
+ if (data.meetup) applyMeetupUpdate(data.meetup);
+ } else {
+ const data = await rsvpMeetup(localMeetup.id);
+ setRsvped(true);
+ if (data.meetup) applyMeetupUpdate(data.meetup);
+ }
+ } finally {
+ setRsvpLoading(false);
+ }
+ };
const mirotalkServer = getMiroTalkServerLabel();
const loungeServer = getLoungeServerLabel();
- const isVideoEvent = isLiveMeetup(meetup);
-
return (
@@ -626,12 +711,12 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
- {modeLabel(meetup.mode, locale)}
- {accessLabel(meetup.accessLevel, locale)}
- {meetup.meetingProvider === "mirotalk" && {MIROTALK_PROVIDER_LABEL}}
+ {modeLabel(localMeetup.mode, locale)}
+ {accessLabel(localMeetup.accessLevel, locale)}
+ {localMeetup.meetingProvider === "mirotalk" && {MIROTALK_PROVIDER_LABEL}}
-
{meetup.emoji} {meetup.city} · {meetup.venue}
-
{meetup.description}
+
{localMeetup.emoji} {localMeetup.city} · {localMeetup.venue}
+
{localMeetup.description}
@@ -639,32 +724,32 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
{locale === "zh" ? "时间" : "Time"}
-
{formatMeetupDate(meetup.date, meetup.time)}
+
{formatMeetupDate(localMeetup.date, localMeetup.time)}
{c.organizer}
-
{meetup.organizer}
+
{localMeetup.organizer}
{locale === "zh" ? "地点" : "Location"}
-
{meetup.venue}
-
{meetup.address}
+
{localMeetup.venue}
+
{localMeetup.address}
- {isVideoEvent && (
+ {isLiveMeetup(localMeetup) && (
{c.provider}
{MIROTALK_PROVIDER_LABEL}
-
{c.room}: {meetup.mirotalkRoom || meetup.id}
+
{c.room}: {localMeetup.mirotalkRoom || localMeetup.id}
{c.relayServer}: {mirotalkServer}
{c.chatService}
{WEB_CHAT_PROVIDER_LABEL}
{c.chatServer}: {loungeServer}
-
{c.chatChannel}: {meetup.loungeChannel || `#${meetup.mirotalkRoom || meetup.id}`}
+
{c.chatChannel}: {localMeetup.loungeChannel || `#${localMeetup.mirotalkRoom || localMeetup.id}`}
{canJoin ? (
@@ -699,18 +784,18 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
) : (
-
{lockedReason(meetup, locale)}
+
{lockedReason(localMeetup, locale)}
)}
- {canJoin && meetup.accessLevel === "public" && (
+ {canJoin && localMeetup.accessLevel === "public" && (
{c.authPublicHint}
)}
{canJoin && (
{c.chatHint}
)}
- {meetup.replayUrl ? (
+ {localMeetup.replayUrl ? (
{c.featureTitle}
- {(featureLabels(meetup.features, locale).length ? featureLabels(meetup.features, locale) : [modeLabel(meetup.mode, locale)]).map((feature) => (
- {feature}
+ {(featureLabels(localMeetup.features, locale).length ? featureLabels(localMeetup.features, locale) : [modeLabel(localMeetup.mode, locale)]).map((feature) => (
+ {feature}
))}
@@ -754,13 +839,50 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
{locale === "zh" ? "当前权限" : "Your access"}
- {canJoin ? accessLabel(meetup.accessLevel, locale) : lockedReason(meetup, locale)}
+ {canJoin ? accessLabel(localMeetup.accessLevel, locale) : lockedReason(localMeetup, locale)}
{locale === "zh" ? "报名人数" : "RSVPs"}
- {meetup.rsvpCount}{meetup.maxAttendees ? ` / ${meetup.maxAttendees}` : ""}
+ {localMeetup.rsvpCount}{localMeetup.maxAttendees ? ` / ${localMeetup.maxAttendees}` : ""}
+ {localMeetup.isUpcoming ? (
+
+ ) : null}
+ {localMeetup.attendees.length > 0 ? (
+
+ {localMeetup.attendees.slice(0, 8).map((attendee, index) => (
+
+ ))}
+
+ ) : null}
+
+
@@ -959,7 +1081,17 @@ export default function MeetupsPage() {
{selectedMeetup && (
- setSelectedMeetup(null)} />
+ setSelectedMeetup(null)}
+ onMeetupUpdated={(updated) => {
+ setMeetups((current) => current.map((item) => (item.id === updated.id ? updated : item)));
+ setSelectedMeetup(updated);
+ }}
+ />
)}
);
diff --git a/app/[locale]/members/[id]/page.tsx b/app/[locale]/members/[id]/page.tsx
new file mode 100644
index 0000000..78036aa
--- /dev/null
+++ b/app/[locale]/members/[id]/page.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Link, useLocale } from "@/i18n/navigation";
+import { useParams } from "next/navigation";
+import Footer from "@/app/components/Footer";
+import SocialGreetButton from "@/app/components/SocialGreetButton";
+import { mediaUrl } from "@/app/lib/media";
+import { fetchPublicProfile, type PublicProfile } from "@/app/lib/social";
+
+const INTENT_LABELS: Record = {
+ friends: "🤝 交友",
+ dating: "🌹 约会",
+ partner: "👥 搭档",
+ roommate: "🏠 室友",
+ cofounder: "🚀 合伙人",
+ explore: "🍜 探店",
+};
+
+export default function MemberProfilePage() {
+ const params = useParams();
+ const profileId = String(params?.id || "");
+ const locale = useLocale();
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!profileId) return;
+ fetchPublicProfile(profileId)
+ .then(setProfile)
+ .finally(() => setLoading(false));
+ }, [profileId]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
+
{locale === "zh" ? "成员资料不存在" : "Profile not found"}
+
+ {locale === "zh" ? "去智能匹配" : "Go to matching"}
+
+
+
+ );
+ }
+
+ const lookingFor = Array.isArray(profile.lookingFor) ? profile.lookingFor : [];
+
+ return (
+
+
+
+
+
+
})
+
+
+ {profile.name}
+ {profile.age ? `,${profile.age}` : ""}
+
+
📍 {profile.location || (locale === "zh" ? "远程" : "Remote")}
+
+
+
+
+
+ {profile.bio ? (
+
+
+ {locale === "zh" ? "自我介绍" : "About"}
+
+ {profile.bio}
+
+ ) : null}
+
+ {lookingFor.length > 0 ? (
+
+
+ {locale === "zh" ? "正在寻找" : "Looking for"}
+
+
+ {lookingFor.map((intent) => (
+
+ {INTENT_LABELS[intent] || intent}
+
+ ))}
+
+
+ ) : null}
+
+ {profile.tags?.length ? (
+
+
+ {locale === "zh" ? "标签" : "Tags"}
+
+
+ {profile.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ {locale === "zh" ? "在匹配里找 TA" : "Find in matching"}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 7368571..3317ad3 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -10,7 +10,9 @@ import CityModal from "../components/CityModal";
import Footer from "@/app/components/Footer";
import { cities, type City } from "../data/cities";
import { HOME_CONFIG } from "@/config";
+import { useDevDebug } from "@/app/context/DevDebugContext";
import { apiFetch } from "@/app/lib/api-client";
+import { cityWeatherKey, type CityWeatherMap } from "@/app/lib/city-weather";
import { mediaUrl } from "@/app/lib/media";
const {
@@ -177,6 +179,7 @@ export default function Home() {
const [sortBy, setSortBy] = useState("score");
const [cityModal, setCityModal] = useState(null);
const [liveCities, setLiveCities] = useState(cities);
+ const [cityWeather, setCityWeather] = useState({});
const [homeContent, setHomeContent] = useState(FALLBACK_HOME_CONTENT);
const [homeStats, setHomeStats] = useState(fallbackStats);
const [memberPhotos, setMemberPhotos] = useState(fallbackMemberPhotos);
@@ -184,6 +187,7 @@ export default function Home() {
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
const locale = useLocale();
const { t } = useTranslation("home");
+ const { isFeatureVisible } = useDevDebug();
useEffect(() => {
apiFetch<{ ok: boolean; items: City[] }>("/api/cities")
@@ -193,6 +197,13 @@ export default function Home() {
}
})
.catch(() => setLiveCities(cities));
+ apiFetch<{ ok: boolean; items: CityWeatherMap }>("/api/weather/cities")
+ .then((data) => {
+ if (data.items && typeof data.items === "object") {
+ setCityWeather(data.items);
+ }
+ })
+ .catch(() => setCityWeather({}));
apiFetch<{ ok: boolean; items: ContentItem[] }>("/api/content/home")
.then((data) => {
const items = (data.items || [])
@@ -260,19 +271,22 @@ export default function Home() {
return safetyOrder(b.safety) - safetyOrder(a.safety);
case "nomads":
return b.nomadsNow - a.nomadsNow;
- case "temperature":
- return b.temperature - a.temperature;
+ case "temperature": {
+ const tempA = cityWeather[cityWeatherKey(a)]?.temperature ?? a.temperature;
+ const tempB = cityWeather[cityWeatherKey(b)]?.temperature ?? b.temperature;
+ return tempB - tempA;
+ }
default:
return b.overallScore - a.overallScore;
}
});
return result;
- }, [activeFilter, sortBy, liveCities]);
+ }, [activeFilter, sortBy, liveCities, cityWeather]);
- const homepageFeatureContent = useMemo(
- () => pickHomepageFeatureContent(homeContent),
- [homeContent]
- );
+ const homepageFeatureContent = useMemo(() => {
+ if (!isFeatureVisible("home.rightFeatureCards")) return [];
+ return pickHomepageFeatureContent(homeContent);
+ }, [homeContent, isFeatureVisible]);
return (
@@ -442,6 +456,7 @@ export default function Home() {
key={city.id}
city={city}
rank={i + 1}
+ liveWeather={cityWeather[cityWeatherKey(city)] ?? null}
onClick={() => setCityModal(city)}
/>
))}
@@ -467,7 +482,7 @@ export default function Home() {
isOpen={!!cityModal}
onClose={() => setCityModal(null)}
/>
-
+
);
}
diff --git a/app/components/ChatNavLink.tsx b/app/components/ChatNavLink.tsx
new file mode 100644
index 0000000..94edcbf
--- /dev/null
+++ b/app/components/ChatNavLink.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Link } from "@/i18n/navigation";
+import { fetchConversations } from "@/app/lib/chat";
+import { resolveAuthUser } from "@/app/lib/api-client";
+
+export default function ChatNavLink() {
+ const [unread, setUnread] = useState(0);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ const session = await resolveAuthUser();
+ if (!session?.user?.id) {
+ if (!cancelled) {
+ setVisible(false);
+ setUnread(0);
+ }
+ return;
+ }
+ try {
+ const data = await fetchConversations();
+ if (!cancelled) {
+ setVisible(true);
+ setUnread(data.unread || 0);
+ }
+ } catch {
+ if (!cancelled) setVisible(false);
+ }
+ };
+ load();
+ const timer = window.setInterval(load, 15000);
+ const onAuth = () => load();
+ window.addEventListener("auth:updated", onAuth);
+ return () => {
+ cancelled = true;
+ window.clearInterval(timer);
+ window.removeEventListener("auth:updated", onAuth);
+ };
+ }, []);
+
+ if (!visible) return null;
+
+ return (
+
+
+ {unread > 0 ? (
+
+ {unread > 9 ? "9+" : unread}
+
+ ) : null}
+
+ );
+}
diff --git a/app/components/CityCard.tsx b/app/components/CityCard.tsx
index 4c58776..f03ea86 100644
--- a/app/components/CityCard.tsx
+++ b/app/components/CityCard.tsx
@@ -3,7 +3,8 @@
import { memo, useState } from "react";
import { City } from "../data/cities";
import { getCitySocialSummary } from "../data/city-social";
-import { useTranslation } from "@/i18n/navigation";
+import { useLocale, useTranslation } from "@/i18n/navigation";
+import { type CityWeatherLive, weatherCodeLabel } from "@/app/lib/city-weather";
function ratingToPercent(rating: string): number {
const m: Record = {
@@ -53,12 +54,16 @@ interface CityCardProps {
city: City;
rank: number;
onClick?: () => void;
+ liveWeather?: CityWeatherLive | null;
}
-function CityCardComponent({ city, rank, onClick }: CityCardProps) {
+function CityCardComponent({ city, rank, onClick, liveWeather }: CityCardProps) {
const [hovered, setHovered] = useState(false);
+ const locale = useLocale();
const { t } = useTranslation("cityCard");
const socialSummary = getCitySocialSummary(city);
+ const temperature = liveWeather?.temperature ?? city.temperature;
+ const weatherLabel = weatherCodeLabel(liveWeather?.weatherCode, locale === "zh" ? "zh" : "en");
const scores = [
{
@@ -67,14 +72,14 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
percent: (city.overallScore / 5) * 100,
},
{
- icon: "💵",
- label: "费用",
- percent: Math.max(15, 100 - city.costPerMonth / 200),
+ icon: "👥",
+ label: "游民",
+ percent: Math.min(95, (city.nomadsNow / 1200) * 100),
},
{
- icon: "📡",
- label: "网速",
- percent: Math.min(95, (city.internetSpeed / 120) * 100),
+ icon: "🌡",
+ label: "天气",
+ percent: Math.max(20, Math.min(95, 100 - Math.abs(temperature - 22) * 4)),
},
{
icon: "👍",
@@ -115,31 +120,26 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{rank}
-
- 📡 {city.internetSpeed}Mbps
+
+ 🌡 {temperature}°C
+ {weatherLabel ? (
+ · {weatherLabel}
+ ) : null}
- {/* Center: city name + country */}
+ {/* Center: city name */}
{city.name}
-
- {city.countryEmoji} {city.country}
-
{/* Bottom: temp + cost + nomads */}
-
-
- 👥 {city.nomadsNow}{t("nomadsHere")}
-
-
- 🌡 {city.temperature}°C
-
-
☕ {socialSummary.coffeechat}
@@ -156,7 +156,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{city.description}
- ¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
+ 👥 {city.nomadsNow.toLocaleString()}{t("nomadsHere")}
@@ -204,7 +204,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
🥾 {socialSummary.cityWalk}
- {city.name} · {city.countryEmoji} {city.country}
+ {city.name}
diff --git a/app/components/CityModal.tsx b/app/components/CityModal.tsx
index ba596c9..df18e6d 100644
--- a/app/components/CityModal.tsx
+++ b/app/components/CityModal.tsx
@@ -18,6 +18,7 @@ import {
import { apiFetch } from "@/app/lib/api-client";
import { cityVolunteerHref } from "@/app/lib/city-slugs";
import { mediaUrl } from "@/app/lib/media";
+import SocialGreetButton from "@/app/components/SocialGreetButton";
const CITY_COORDS: Record = {
大理: [25.7, 100.2],
@@ -653,7 +654,9 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
-
{member.name}{member.age ? `,${member.age}` : ""}
+
+ {member.name}{member.age ? `,${member.age}` : ""}
+
{meta.badge}
@@ -671,9 +674,7 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
{member.availability}
-
- 打招呼
-
+
);
diff --git a/app/components/DevDebugFab.tsx b/app/components/DevDebugFab.tsx
new file mode 100644
index 0000000..5da336b
--- /dev/null
+++ b/app/components/DevDebugFab.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useMemo } from "react";
+import { getDebugFeatureCategories } from "@/config/debug-features";
+import { useDevDebug } from "@/app/context/DevDebugContext";
+
+function ToggleSwitch({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+export default function DevDebugFab() {
+ const {
+ isDev,
+ panelOpen,
+ setPanelOpen,
+ features,
+ isFeatureVisible,
+ setFeatureVisible,
+ resetAll,
+ overrides,
+ } = useDevDebug();
+
+ const categories = useMemo(() => getDebugFeatureCategories(), []);
+ const overrideCount = Object.keys(overrides).length;
+
+ if (!isDev) return null;
+
+ return (
+ <>
+ {panelOpen && (
+