This commit is contained in:
eric
2026-06-08 06:05:31 -05:00
parent ebfec37e47
commit 06ab949d81
77 changed files with 3646 additions and 358 deletions

View File

@@ -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({
</span>
) : null}
</div>
<span className="flex-shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500">
{timeLabel}
</span>
<div className="flex flex-shrink-0 flex-col items-end gap-2">
{action}
<span className="rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500">
{timeLabel}
</span>
</div>
</>
);
if (onSelect) {
@@ -56,7 +64,15 @@ function ProfileRow({
return <div className={className}>{content}</div>;
}
export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
export default function LikesPanel({
likes,
mutual,
formatTime,
t,
onSelectProfile,
onLikeBack,
likingBackId,
}: LikesPanelProps) {
return (
<div className="space-y-6">
<section>
@@ -77,6 +93,21 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
profile={profile}
timeLabel={formatTime(profile.likedAt)}
onSelect={() => onSelectProfile?.(profile)}
action={
onLikeBack ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onLikeBack(profile);
}}
disabled={likingBackId === profile.id}
className="rounded-full bg-[#ff4d4f] px-3 py-1 text-[11px] font-semibold text-white disabled:opacity-60"
>
{likingBackId === profile.id ? "..." : t("likeBack")}
</button>
) : null
}
/>
))
) : (

View File

@@ -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({
<Link
href={`/feedback?type=report&target=${encodeURIComponent(profile.id)}`}
className="text-sm font-medium text-[#ff4d4f] hover:text-red-600"
onPointerDown={(e) => e.stopPropagation()}
>
{t("report")}
</Link>
</div>
<div className="mb-6 mt-12 flex justify-center">
<img
src={mediaUrl(profile.photo)}
alt={profile.name}
className="h-28 w-28 rounded-full border-4 border-white/50 object-cover shadow-lg"
/>
<Link href={`/members/${profile.id}`} onPointerDown={(e) => e.stopPropagation()}>
<img
src={mediaUrl(profile.photo)}
alt={profile.name}
className="h-28 w-28 rounded-full border-4 border-white/50 object-cover shadow-lg transition hover:scale-105"
/>
</Link>
</div>
<div className="mb-4 text-center">
@@ -114,11 +121,17 @@ export default function SwipeCard({
<p className="mb-8 px-2 text-center text-sm leading-relaxed text-white/95">{profile.bio}</p>
<div className="flex items-center justify-center gap-8">
<div
className={`flex items-center justify-center ${showSuperLike ? "gap-5" : "gap-8"}`}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={onDislike}
onClick={(e) => {
e.stopPropagation();
onDislike();
}}
className="flex h-16 w-16 items-center justify-center rounded-full bg-white text-[#ff4d4f] shadow-lg transition-all hover:scale-105 hover:bg-red-50 active:scale-95"
aria-label={t("dislike")}
>
@@ -126,10 +139,29 @@ export default function SwipeCard({
</button>
<span className="text-xs font-medium text-white/80">{t("dislike")}</span>
</div>
{showSuperLike && onSuperLike ? (
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSuperLike();
}}
className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-400 text-white shadow-lg ring-2 ring-amber-200 transition-all hover:scale-105 hover:bg-amber-500 active:scale-95"
aria-label={t("superlike")}
>
<span className="text-xl"></span>
</button>
<span className="text-xs font-medium text-white/80">{t("superlike")}</span>
</div>
) : null}
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={onLike}
onClick={(e) => {
e.stopPropagation();
onLike();
}}
className="flex h-16 w-16 items-center justify-center rounded-full bg-[#22c55e] text-white shadow-lg transition-all hover:scale-105 hover:bg-[#16a34a] active:scale-95"
aria-label={t("like")}
>

View File

@@ -13,6 +13,8 @@ interface SwipeStackProps {
isAnimatingOut: "left" | "right" | null;
onLike: () => void;
onDislike: () => void;
onSuperLike?: () => void;
showSuperLike?: boolean;
onBeginDrag: (clientX: number) => void;
onMoveDrag: (clientX: number) => void;
onEndDrag: () => void;
@@ -28,6 +30,8 @@ export default function SwipeStack({
isAnimatingOut,
onLike,
onDislike,
onSuperLike,
showSuperLike,
onBeginDrag,
onMoveDrag,
onEndDrag,
@@ -59,6 +63,8 @@ export default function SwipeStack({
isAnimatingOut={isAnimatingOut}
onLike={onLike}
onDislike={onDislike}
onSuperLike={onSuperLike}
showSuperLike={showSuperLike}
onBeginDrag={onBeginDrag}
onMoveDrag={onMoveDrag}
onEndDrag={onEndDrag}

View File

@@ -67,12 +67,15 @@ function DatingPageInner() {
profile: DatingProfile;
conversationId?: string;
} | null>(null);
const [likingBackId, setLikingBackId] = useState<string | null>(null);
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null);
const startXRef = useRef(0);
const dragXRef = useRef(0);
const swipeLockRef = useRef(false);
const lastSwipedRef = useRef<DatingProfile | null>(null);
const cityOptions = useMemo(
() =>
@@ -170,30 +173,40 @@ function DatingPageInner() {
const advanceProfile = () => {
window.setTimeout(() => {
setCurrentIndex((index) => (hasMoreProfiles ? index + 1 : index));
dragXRef.current = 0;
setDragX(0);
setIsAnimatingOut(null);
swipeLockRef.current = false;
}, 180);
};
const performSwipe = async (action: "like" | "dislike") => {
const performSwipe = async (action: "like" | "dislike" | "superlike") => {
if (!currentProfile || swipeLockRef.current) return;
if (!isLoggedIn) {
setShowLoginPrompt(true);
dragXRef.current = 0;
setDragX(0);
return;
}
if (!quota.vip && quota.remaining <= 0) {
setShowQuotaPrompt(true);
dragXRef.current = 0;
setDragX(0);
return;
}
if (action === "superlike" && !quota.vip && !isVip) {
setShowQuotaPrompt(true);
return;
}
swipeLockRef.current = true;
setIsAnimatingOut(action === "like" ? "right" : "left");
setDragX(action === "like" ? 260 : -260);
setIsAnimatingOut(action === "dislike" ? "left" : "right");
const animX = action === "dislike" ? -260 : 260;
dragXRef.current = animX;
setDragX(animX);
try {
if (!usingFallback) {
const result = await saveSwipe(currentProfile.id, action, activeTab);
lastSwipedRef.current = currentProfile;
setCanUndo(true);
if (result.matched) {
setMatchCelebration({
@@ -201,34 +214,42 @@ function DatingPageInner() {
conversationId: result.match?.conversationId,
});
refreshSidePanels();
} else if (action === "like") {
} else if (action === "like" || action === "superlike") {
refreshSidePanels();
}
refreshQuota();
} else {
setCanUndo(true);
setCanUndo(false);
}
advanceProfile();
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("滑动次数") || message.includes("升级会员")) {
if (message.includes("滑动次数") || message.includes("升级会员") || message.includes("超级赞")) {
setShowQuotaPrompt(true);
} else {
} else if (message.includes("完善加入资料") || message.includes("登录")) {
setShowLoginPrompt(true);
} else if (message) {
setShowLoginPrompt(true);
}
swipeLockRef.current = false;
setIsAnimatingOut(null);
dragXRef.current = 0;
setDragX(0);
return;
} finally {
advanceProfile();
}
};
const handleUndo = async () => {
if (!canUndo || currentIndex <= 0) return;
const lastProfile = lastSwipedRef.current;
if (!canUndo || !lastProfile) return;
try {
if (!usingFallback) await undoSwipe();
setCurrentIndex((index) => Math.max(0, index - 1));
setProfiles((prev) => {
if (prev.some((profile) => profile.id === lastProfile.id)) return prev;
const next = [...prev];
next.splice(currentIndex, 0, lastProfile);
return next;
});
lastSwipedRef.current = null;
setCanUndo(false);
refreshQuota();
} catch {
@@ -238,6 +259,7 @@ function DatingPageInner() {
const handleLike = () => performSwipe("like");
const handleDislike = () => performSwipe("dislike");
const handleSuperLike = () => performSwipe("superlike");
const beginDrag = (clientX: number) => {
startXRef.current = clientX;
@@ -246,20 +268,24 @@ function DatingPageInner() {
const moveDrag = (clientX: number) => {
if (!isDragging) return;
setDragX(Math.max(-180, Math.min(180, clientX - startXRef.current)));
const next = Math.max(-180, Math.min(180, clientX - startXRef.current));
dragXRef.current = next;
setDragX(next);
};
const endDrag = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragX > 90) {
const dx = dragXRef.current;
if (dx > 90) {
handleLike();
return;
}
if (dragX < -90) {
if (dx < -90) {
handleDislike();
return;
}
dragXRef.current = 0;
setDragX(0);
};
@@ -269,6 +295,36 @@ function DatingPageInner() {
setMobilePanelOpen(false);
};
const handleLikeBack = async (profile: DatingProfile) => {
if (!isLoggedIn) {
setShowLoginPrompt(true);
return;
}
if (usingFallback) {
jumpToProfile(profile);
return;
}
setLikingBackId(profile.id);
try {
const result = await saveSwipe(profile.id, "like", activeTab);
if (result.matched) {
setMatchCelebration({
profile,
conversationId: result.match?.conversationId,
});
}
refreshSidePanels();
refreshQuota();
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("滑动次数") || message.includes("升级会员")) {
setShowQuotaPrompt(true);
}
} finally {
setLikingBackId(null);
}
};
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="border-b border-gray-100 bg-white/80 backdrop-blur dark:border-gray-800 dark:bg-gray-950/80">
@@ -329,6 +385,8 @@ function DatingPageInner() {
formatTime={formatTime}
t={t}
onSelectProfile={jumpToProfile}
onLikeBack={handleLikeBack}
likingBackId={likingBackId}
/>
</div>
</aside>
@@ -348,6 +406,8 @@ function DatingPageInner() {
isAnimatingOut={isAnimatingOut}
onLike={handleLike}
onDislike={handleDislike}
onSuperLike={handleSuperLike}
showSuperLike={quota.vip || isVip}
onBeginDrag={beginDrag}
onMoveDrag={moveDrag}
onEndDrag={endDrag}
@@ -420,6 +480,8 @@ function DatingPageInner() {
formatTime={formatTime}
t={t}
onSelectProfile={jumpToProfile}
onLikeBack={handleLikeBack}
likingBackId={likingBackId}
/>
</div>
</div>

View File

@@ -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<DatingProfile[]> {
export async function saveSwipe(
profileId: string,
action: "like" | "dislike",
action: "like" | "dislike" | "superlike",
intent: MatchIntent
): Promise<SwipeResult> {
return apiFetch<SwipeResult>("/api/matches/swipes", {

View File

@@ -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<Record<string, unknown> | null>(null);
const [replies, setReplies] = useState<DiscussionReply[]>([]);
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 (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="mx-auto max-w-3xl animate-pulse px-4 py-10">
<div className="h-40 rounded-2xl bg-gray-200 dark:bg-gray-800" />
</div>
</div>
);
}
if (!topic) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#fafafa] px-4 dark:bg-gray-950">
<div className="text-center">
<p className="text-gray-500 dark:text-gray-400">{error || (locale === "zh" ? "话题不存在" : "Topic not found")}</p>
<Link href="/discuss" className="mt-4 inline-block text-[#ff4d4f]">
{locale === "zh" ? "返回讨论区" : "Back to discussions"}
</Link>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
<Link href="/discuss" className="text-sm text-gray-500 hover:text-[#ff4d4f] dark:text-gray-400">
{locale === "zh" ? "返回讨论区" : "Back"}
</Link>
<article className="mt-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="rounded-full bg-red-50 px-2.5 py-0.5 text-xs text-[#ff4d4f] dark:bg-red-950/30">
{tag}
</span>
))}
</div>
<div className="mt-3 flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{String(topic.title || "")}</h1>
{pinned ? (
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
📌 {locale === "zh" ? "置顶" : "Pinned"}
</span>
) : null}
</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{String(topic.author || "社区成员")}
{topic.city ? ` · ${String(topic.city)}` : ""}
{` · 👁 ${Number(topic.views || 0)} · 💬 ${Number(topic.replies || 0)}`}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleLike}
disabled={!isLoggedIn || liking}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition ${
likedByMe
? "bg-red-50 text-[#ff4d4f] dark:bg-red-950/30"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
} disabled:opacity-50`}
>
{likedByMe ? "❤️" : "🤍"} {likes}
</button>
{isAuthor ? (
<button
type="button"
onClick={handlePin}
disabled={pinning}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition ${
pinned
? "bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
} disabled:opacity-50`}
>
📌 {pinned ? (locale === "zh" ? "取消置顶" : "Unpin") : locale === "zh" ? "置顶话题" : "Pin topic"}
</button>
) : null}
</div>
{topic.excerpt ? (
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-gray-700 dark:text-gray-300">
{String(topic.excerpt)}
</p>
) : null}
</article>
<section className="mt-6 space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "回复" : "Replies"} ({replies.length})
</h2>
{replies.length ? (
replies.map((reply) => (
<div
key={reply.id}
className="rounded-xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{reply.author || (locale === "zh" ? "成员" : "Member")}
</span>
<span className="text-xs text-gray-400">{reply.created ? new Date(reply.created).toLocaleString() : ""}</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-gray-700 dark:text-gray-300">{reply.body}</p>
</div>
))
) : (
<p className="rounded-xl border border-dashed border-gray-200 p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{locale === "zh" ? "还没有回复,来抢沙发吧" : "No replies yet"}
</p>
)}
</section>
<form onSubmit={handleSubmit} className="mt-6 rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={4}
placeholder={locale === "zh" ? "写下你的经验、建议或提问..." : "Share your thoughts..."}
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
{error ? <p className="mt-2 text-sm text-red-500">{error}</p> : null}
<div className="mt-3 flex justify-end">
<button
type="submit"
disabled={posting || !draft.trim()}
className="rounded-full bg-[#ff4d4f] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
{posting ? (locale === "zh" ? "发送中..." : "Posting...") : locale === "zh" ? "发表回复" : "Reply"}
</button>
</div>
</form>
</main>
<Footer />
</div>
);
}

View File

@@ -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<string | null>(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() {
<div className="space-y-3 sm:space-y-4">
{filteredTopics.map((topic) => (
<div
<Link
key={topic.id}
className="bg-white dark:bg-gray-900 rounded-xl p-4 sm:p-5 shadow-sm border border-gray-100 dark:border-gray-800 hover:shadow-md transition-shadow cursor-pointer"
href={`/discuss/${topic.id}`}
className="block bg-white dark:bg-gray-900 rounded-xl p-4 sm:p-5 shadow-sm border border-gray-100 dark:border-gray-800 hover:shadow-md transition-shadow"
>
<div className="flex items-start gap-3 sm:gap-4">
<img
@@ -324,11 +356,20 @@ export default function DiscussPage() {
<span className="hidden sm:inline">·</span>
<span>👁 {topic.views}</span>
<span>💬 {topic.replies}</span>
<span> {topic.likes}</span>
<button
type="button"
onClick={(event) => handleLike(topic.id, event)}
disabled={likingId === topic.id}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition ${
topic.likedByMe ? "bg-red-50 text-[#ff4d4f] dark:bg-red-950/30" : "hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
{topic.likedByMe ? "❤️" : "🤍"} {topic.likes}
</button>
</div>
</div>
</div>
</div>
</Link>
))}
</div>

View File

@@ -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() {
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
</div>
{isAlreadyRecorded && (
<div className="mx-5 mt-4 rounded-xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-900/50 dark:bg-sky-950/40 dark:text-sky-200 sm:mx-8">
使
</div>
)}
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100 sm:text-2xl">
🌍

View File

@@ -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 (
<LocaleProvider locale={locale as Locale} messages={messages}>
<SetLangAttr />
<RouteAwareNavbar />
{children}
<DevDebugTools>
<SetLangAttr />
<RouteAwareNavbar />
{children}
</DevDebugTools>
</LocaleProvider>
);
}

View File

@@ -99,6 +99,7 @@ export default function HostMeetupPage() {
const locale = useLocale();
const c = getCopy(locale);
const [submitted, setSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<HostMeetupForm>({
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}
</p>
{submitError && (
<p className="rounded-lg border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300">
{submitError}
</p>
)}
<button
type="submit"
disabled={submitting}

View File

@@ -22,10 +22,33 @@ import {
} from "@/app/lib/meetups";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
import { cancelMeetupRsvp, fetchMeetupRsvpStatus, rsvpMeetup } from "@/app/lib/social";
import MeetupSocialPanel from "@/app/components/meetups/MeetupSocialPanel";
function AttendeeAvatar({ attendee, className }: { attendee: Attendee; className?: string }) {
const image = (
<img
src={mediaUrl(attendee.photo)}
alt={attendee.name}
title={attendee.name}
className={className || "h-8 w-8 rounded-full border border-white object-cover shadow-sm dark:border-gray-800"}
/>
);
if (attendee.profileId) {
return (
<Link href={`/members/${attendee.profileId}`} className="transition hover:scale-105">
{image}
</Link>
);
}
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<Meetup> & { 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<Meetup> & { 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
<div className="mt-auto flex items-center gap-1 border-t border-gray-100 pt-4 dark:border-gray-700">
<div className="flex -space-x-2">
{meetup.attendees.map((a, index) => (
<img key={`${a.name}-${index}`} src={mediaUrl(a.photo)} alt={a.name} className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-800 sm:h-8 sm:w-8" title={a.name} />
<AttendeeAvatar
key={`${a.name}-${index}`}
attendee={a}
className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-800 sm:h-8 sm:w-8"
/>
))}
</div>
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500">{locale === "zh" ? "已报名成员" : "attendees"}</span>
@@ -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<string, unknown>) => {
const next = mapMeetup({ ...raw, id: String(raw.id || localMeetup.id) } as Partial<Meetup> & { 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 (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<button type="button" className="fixed inset-0 cursor-default bg-black/55 backdrop-blur-sm" onClick={onClose} aria-label={locale === "zh" ? "关闭活动详情" : "Close event details"} />
@@ -626,12 +711,12 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
</svg>
</button>
<div className="flex flex-wrap gap-2 pr-12">
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{modeLabel(meetup.mode, locale)}</span>
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{accessLabel(meetup.accessLevel, locale)}</span>
{meetup.meetingProvider === "mirotalk" && <span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{MIROTALK_PROVIDER_LABEL}</span>}
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{modeLabel(localMeetup.mode, locale)}</span>
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{accessLabel(localMeetup.accessLevel, locale)}</span>
{localMeetup.meetingProvider === "mirotalk" && <span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{MIROTALK_PROVIDER_LABEL}</span>}
</div>
<h2 className="mt-5 text-2xl font-bold sm:text-3xl">{meetup.emoji} {meetup.city} · {meetup.venue}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/88">{meetup.description}</p>
<h2 className="mt-5 text-2xl font-bold sm:text-3xl">{localMeetup.emoji} {localMeetup.city} · {localMeetup.venue}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/88">{localMeetup.description}</p>
</div>
<div className="grid gap-5 p-5 sm:p-7 lg:grid-cols-[1.1fr_0.9fr]">
@@ -639,32 +724,32 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{locale === "zh" ? "时间" : "Time"}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMeetupDate(meetup.date, meetup.time)}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMeetupDate(localMeetup.date, localMeetup.time)}</p>
</div>
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{c.organizer}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.organizer}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{localMeetup.organizer}</p>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{locale === "zh" ? "地点" : "Location"}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.venue}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{meetup.address}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{localMeetup.venue}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{localMeetup.address}</p>
</div>
{isVideoEvent && (
{isLiveMeetup(localMeetup) && (
<div className="rounded-xl border border-emerald-100 bg-emerald-50/70 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/30">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">{c.provider}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{MIROTALK_PROVIDER_LABEL}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.room}: {meetup.mirotalkRoom || meetup.id}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.room}: {localMeetup.mirotalkRoom || localMeetup.id}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.relayServer}: {mirotalkServer}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">{c.chatService}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{WEB_CHAT_PROVIDER_LABEL}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.chatServer}: {loungeServer}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.chatChannel}: {meetup.loungeChannel || `#${meetup.mirotalkRoom || meetup.id}`}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.chatChannel}: {localMeetup.loungeChannel || `#${localMeetup.mirotalkRoom || localMeetup.id}`}</p>
</div>
{canJoin ? (
<div className="flex flex-wrap gap-2">
@@ -699,18 +784,18 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
</button>
</div>
) : (
<p className="rounded-lg border border-red-200 bg-white px-3 py-2 text-sm font-semibold text-red-600 dark:border-red-900/60 dark:bg-gray-900 dark:text-red-300">{lockedReason(meetup, locale)}</p>
<p className="rounded-lg border border-red-200 bg-white px-3 py-2 text-sm font-semibold text-red-600 dark:border-red-900/60 dark:bg-gray-900 dark:text-red-300">{lockedReason(localMeetup, locale)}</p>
)}
</div>
{canJoin && meetup.accessLevel === "public" && (
{canJoin && localMeetup.accessLevel === "public" && (
<p className="mt-3 text-xs text-emerald-700 dark:text-emerald-300">{c.authPublicHint}</p>
)}
{canJoin && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">{c.chatHint}</p>
)}
{meetup.replayUrl ? (
{localMeetup.replayUrl ? (
<a
href={meetup.replayUrl}
href={localMeetup.replayUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex text-sm font-semibold text-emerald-700 underline dark:text-emerald-300"
@@ -720,7 +805,7 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
) : null}
{embedOpen && canJoin && (
<iframe
title={`MiroTalk ${meetup.city}`}
title={`MiroTalk ${localMeetup.city}`}
src={joinUrl}
className="mt-4 h-[360px] w-full rounded-xl border border-emerald-200 bg-black dark:border-emerald-900 sm:h-[460px]"
allow="camera; microphone; display-capture; fullscreen; clipboard-read; clipboard-write"
@@ -735,8 +820,8 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
<div className="rounded-xl border border-gray-100 p-4 dark:border-gray-800">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">{c.featureTitle}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{(featureLabels(meetup.features, locale).length ? featureLabels(meetup.features, locale) : [modeLabel(meetup.mode, locale)]).map((feature) => (
<Badge key={feature} tone={meetup.mode === "offline" ? "gray" : "green"}>{feature}</Badge>
{(featureLabels(localMeetup.features, locale).length ? featureLabels(localMeetup.features, locale) : [modeLabel(localMeetup.mode, locale)]).map((feature) => (
<Badge key={feature} tone={localMeetup.mode === "offline" ? "gray" : "green"}>{feature}</Badge>
))}
</div>
</div>
@@ -754,13 +839,50 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
<div className="rounded-xl border border-gray-100 p-4 dark:border-gray-800">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">{locale === "zh" ? "当前权限" : "Your access"}</span>
<Badge tone={canJoin ? "green" : "red"}>{canJoin ? accessLabel(meetup.accessLevel, locale) : lockedReason(meetup, locale)}</Badge>
<Badge tone={canJoin ? "green" : "red"}>{canJoin ? accessLabel(localMeetup.accessLevel, locale) : lockedReason(localMeetup, locale)}</Badge>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">{locale === "zh" ? "报名人数" : "RSVPs"}</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.rsvpCount}{meetup.maxAttendees ? ` / ${meetup.maxAttendees}` : ""}</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{localMeetup.rsvpCount}{localMeetup.maxAttendees ? ` / ${localMeetup.maxAttendees}` : ""}</span>
</div>
{localMeetup.isUpcoming ? (
<button
type="button"
onClick={handleRsvp}
disabled={rsvpLoading || !viewer.user?.id}
className={`mt-4 w-full rounded-xl px-4 py-3 text-sm font-semibold transition ${
rsvped
? "border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300"
: "bg-[#ff4d4f] text-white hover:bg-[#ff7a45]"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
{!viewer.user?.id
? locale === "zh"
? "登录后报名"
: "Log in to RSVP"
: rsvpLoading
? locale === "zh"
? "处理中..."
: "Processing..."
: rsvped
? locale === "zh"
? "已报名 · 取消"
: "Going · Cancel"
: locale === "zh"
? "我要参加"
: "RSVP"}
</button>
) : null}
{localMeetup.attendees.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{localMeetup.attendees.slice(0, 8).map((attendee, index) => (
<AttendeeAvatar key={`${attendee.name}-${index}`} attendee={attendee} />
))}
</div>
) : null}
</div>
<MeetupSocialPanel meetupId={localMeetup.id} city={localMeetup.city} locale={locale} compact />
</aside>
</div>
</section>
@@ -959,7 +1081,17 @@ export default function MeetupsPage() {
</main>
{selectedMeetup && (
<MeetupDetailModal key={selectedMeetup.id} meetup={selectedMeetup} locale={locale} viewer={viewer} onClose={() => setSelectedMeetup(null)} />
<MeetupDetailModal
key={selectedMeetup.id}
meetup={selectedMeetup}
locale={locale}
viewer={viewer}
onClose={() => setSelectedMeetup(null)}
onMeetupUpdated={(updated) => {
setMeetups((current) => current.map((item) => (item.id === updated.id ? updated : item)));
setSelectedMeetup(updated);
}}
/>
)}
</div>
);

View File

@@ -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<string, string> = {
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<PublicProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!profileId) return;
fetchPublicProfile(profileId)
.then(setProfile)
.finally(() => setLoading(false));
}, [profileId]);
if (loading) {
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="mx-auto max-w-2xl animate-pulse px-4 py-10">
<div className="h-64 rounded-2xl bg-gray-200 dark:bg-gray-800" />
</div>
</div>
);
}
if (!profile) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#fafafa] px-4 dark:bg-gray-950">
<div className="text-center">
<p className="text-gray-500">{locale === "zh" ? "成员资料不存在" : "Profile not found"}</p>
<Link href="/dating" className="mt-4 inline-block text-[#ff4d4f]">
{locale === "zh" ? "去智能匹配" : "Go to matching"}
</Link>
</div>
</div>
);
}
const lookingFor = Array.isArray(profile.lookingFor) ? profile.lookingFor : [];
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6">
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="bg-gradient-to-br from-[#ff4d4f] to-[#ff7a45] px-6 py-8 text-white">
<div className="flex items-center gap-4">
<img
src={mediaUrl(profile.photo)}
alt={profile.name || "member"}
className="h-20 w-20 rounded-full border-4 border-white/30 object-cover"
/>
<div>
<h1 className="text-2xl font-bold">
{profile.name}
{profile.age ? `${profile.age}` : ""}
</h1>
<p className="mt-1 text-sm text-white/85">📍 {profile.location || (locale === "zh" ? "远程" : "Remote")}</p>
</div>
</div>
</div>
<div className="space-y-5 p-6">
{profile.bio ? (
<section>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "自我介绍" : "About"}
</h2>
<p className="mt-2 text-sm leading-7 text-gray-600 dark:text-gray-300">{profile.bio}</p>
</section>
) : null}
{lookingFor.length > 0 ? (
<section>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "正在寻找" : "Looking for"}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
{lookingFor.map((intent) => (
<span
key={intent}
className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{INTENT_LABELS[intent] || intent}
</span>
))}
</div>
</section>
) : null}
{profile.tags?.length ? (
<section>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "标签" : "Tags"}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
{profile.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-red-50 px-3 py-1 text-xs text-[#ff4d4f] dark:bg-red-950/30"
>
{tag}
</span>
))}
</div>
</section>
) : null}
<div className="flex flex-wrap gap-3 border-t border-gray-100 pt-5 dark:border-gray-800">
<Link
href={`/dating?intent=${lookingFor[0] || "friends"}${profile.citySlug ? `&city=${profile.citySlug}` : ""}`}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 dark:border-gray-700 dark:text-gray-200"
>
{locale === "zh" ? "在匹配里找 TA" : "Find in matching"}
</Link>
<SocialGreetButton profileId={profile.id} />
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -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<City | null>(null);
const [liveCities, setLiveCities] = useState<City[]>(cities);
const [cityWeather, setCityWeather] = useState<CityWeatherMap>({});
const [homeContent, setHomeContent] = useState<ContentItem[]>(FALLBACK_HOME_CONTENT);
const [homeStats, setHomeStats] = useState<HomeStats>(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 (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
@@ -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)}
/>
<Footer />
<Footer minimal />
</div>
);
}

View File

@@ -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 (
<Link
href="/chat"
className="relative inline-flex items-center justify-center rounded-lg p-2 text-gray-600 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="私信"
title="私信"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h8M8 14h5m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{unread > 0 ? (
<span className="absolute -right-0.5 -top-0.5 min-w-[18px] rounded-full bg-[#ff4d4f] px-1 text-center text-[10px] font-bold leading-[18px] text-white">
{unread > 9 ? "9+" : unread}
</span>
) : null}
</Link>
);
}

View File

@@ -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<string, number> = {
@@ -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) {
<span className="text-white/70 text-base sm:text-xl font-bold">
{rank}
</span>
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5">
📡 {city.internetSpeed}Mbps
<span
className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5"
title={liveWeather ? (weatherLabel ? `${weatherLabel} · 实时` : "实时天气") : undefined}
>
🌡 {temperature}°C
{weatherLabel ? (
<span className="hidden sm:inline opacity-80">· {weatherLabel}</span>
) : null}
</span>
</div>
{/* Center: city name + country */}
{/* Center: city name */}
<div className="text-center -mt-2">
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
{city.name}
</h3>
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
{/* Bottom: temp + cost + nomads */}
<div>
<div className="mb-1 flex flex-wrap items-center gap-1">
<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}{t("nomadsHere")}
</span>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
🌡 {city.temperature}°C
</span>
</div>
<div className="mb-1.5 grid grid-cols-3 gap-1">
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
{socialSummary.coffeechat}
@@ -156,7 +156,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{city.description}
</span>
<span className="font-semibold shrink-0">
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
👥 {city.nomadsNow.toLocaleString()}{t("nomadsHere")}
</span>
</div>
</div>
@@ -204,7 +204,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
<span className="rounded-full bg-white/10 px-2 py-1">🥾 {socialSummary.cityWalk}</span>
</div>
<div className="text-white/40">
{city.name} · {city.countryEmoji} {city.country}
{city.name}
</div>
</div>
</div>

View File

@@ -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<string, [number, number]> = {
: [25.7, 100.2],
@@ -653,7 +654,9 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
<img src={mediaUrl(member.photo)} alt={member.name} className="h-14 w-14 rounded-full object-cover shadow-sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-gray-900 dark:text-gray-100">{member.name}{member.age ? `${member.age}` : ""}</h3>
<Link href={`/members/${member.id}`} className="font-bold text-gray-900 hover:text-[#ff4d4f] dark:text-gray-100">
{member.name}{member.age ? `${member.age}` : ""}
</Link>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${meta.tone}`}>{meta.badge}</span>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -671,9 +674,7 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
</div>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-gray-100 pt-3 dark:border-gray-800">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{member.availability}</span>
<Link href="/messages" className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500">
</Link>
<SocialGreetButton profileId={member.id} />
</div>
</article>
);

View File

@@ -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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
checked ? "bg-[#ff4d4f]" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
checked ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
);
}
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 && (
<button
type="button"
aria-label="关闭调试面板"
className="fixed inset-0 z-[9998] bg-black/20 backdrop-blur-[1px]"
onClick={() => setPanelOpen(false)}
/>
)}
<div className="fixed bottom-5 right-5 z-[9999] flex flex-col items-end gap-3">
{panelOpen && (
<div className="w-[min(92vw,360px)] rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-gray-900 dark:text-gray-100"></h2>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
·
</p>
</div>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label="关闭"
>
</button>
</div>
</div>
<div className="max-h-[min(60vh,420px)] overflow-y-auto p-4 space-y-5">
{categories.map((category) => (
<section key={category}>
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{category}
</h3>
<ul className="space-y-2">
{features
.filter((feature) => feature.category === category)
.map((feature) => {
const visible = isFeatureVisible(feature.id);
return (
<li
key={feature.id}
className="flex items-center justify-between gap-3 rounded-xl border border-gray-100 bg-gray-50 px-3 py-2.5 dark:border-gray-800 dark:bg-gray-800/60"
>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{feature.label}
</p>
<p className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{feature.description}
</p>
</div>
<ToggleSwitch
checked={visible}
onChange={(next) => setFeatureVisible(feature.id, next)}
label={`${feature.label} ${visible ? "显示" : "隐藏"}`}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{overrideCount > 0 ? `已覆盖 ${overrideCount}` : "使用默认配置"}
</span>
<button
type="button"
onClick={resetAll}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
</button>
</div>
</div>
)}
<button
type="button"
onClick={() => setPanelOpen(!panelOpen)}
className={`flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-all ${
panelOpen
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "bg-[#ff4d4f] text-white hover:bg-[#ff7a45]"
}`}
aria-label={panelOpen ? "关闭调试模式" : "打开调试模式"}
title="调试模式"
>
<span className="text-lg">{panelOpen ? "✕" : "🛠️"}</span>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import { type ReactNode } from "react";
import { DevDebugProvider } from "@/app/context/DevDebugContext";
import DevDebugFab from "./DevDebugFab";
export default function DevDebugTools({ children }: { children: ReactNode }) {
return (
<DevDebugProvider>
{children}
<DevDebugFab />
</DevDebugProvider>
);
}

View File

@@ -4,13 +4,19 @@ import { memo } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import { FOOTER_SECTIONS } from "@/config";
function FooterComponent() {
interface FooterProps {
/** Hide link columns (about, support, legal, social) — used on homepage */
minimal?: boolean;
}
function FooterComponent({ minimal = false }: FooterProps) {
const { t } = useTranslation("footer");
const { t: tCommon } = useTranslation("common");
return (
<footer className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-12">
{!minimal && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 mb-8">
{FOOTER_SECTIONS.map((section) => (
<div key={section.titleKey}>
@@ -41,7 +47,8 @@ function FooterComponent() {
</div>
))}
</div>
<div className="pt-8 border-t border-gray-200 dark:border-gray-800 flex flex-col sm:flex-row items-center justify-between gap-4">
)}
<div className={`flex flex-col sm:flex-row items-center justify-between gap-4${minimal ? "" : " pt-8 border-t border-gray-200 dark:border-gray-800"}`}>
<div className="flex items-center gap-2">
<span className="text-xl">🌏</span>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{tCommon("siteNameShort")}</span>

View File

@@ -13,6 +13,7 @@ import type { PayChannel } from "@/app/lib/payment";
import { apiFetch, fetchAuthMe, syncAuthSession } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
import { useDevDebug } from "@/app/context/DevDebugContext";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
@@ -65,6 +66,8 @@ function buildPendingUserId(email: string): string {
export default function HeroSection() {
const { t: tHero } = useTranslation("hero");
const locale = useLocale();
const { isFeatureVisible } = useDevDebug();
const showMediaPress = isFeatureVisible("home.mediaPress");
const [email, setEmail] = useState("");
const [joinModalOpen, setJoinModalOpen] = useState(false);
const [joinModalVipOnly, setJoinModalVipOnly] = useState(false);
@@ -240,23 +243,25 @@ export default function HeroSection() {
</span>
</div>
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
{tHero("mediaPress")}
</p>
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
(name) => (
<span
key={name}
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
>
{name}
</span>
)
)}
{showMediaPress && (
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
{tHero("mediaPress")}
</p>
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
(name) => (
<span
key={name}
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
>
{name}
</span>
)
)}
</div>
</div>
</div>
)}
</div>
{/* Right Column: Video + Email */}

View File

@@ -1,15 +1,18 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import { resolveAuthUser } from "@/app/lib/api-client";
import { getNavLinks } from "@/config";
import { getAllNavLinks } from "@/config";
import { getNavLabelDebugFeatureId } from "@/config/debug-features";
import { useDevDebug } from "@/app/context/DevDebugContext";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
import SiteMenu from "./SiteMenu";
import UserAvatar from "./UserAvatar";
import NotificationBell from "./NotificationBell";
import ChatNavLink from "./ChatNavLink";
export default function NavbarComponent() {
const { t } = useTranslation("common");
@@ -23,7 +26,18 @@ export default function NavbarComponent() {
const [userEmail, setUserEmail] = useState<string | null>(null);
const [userVip, setUserVip] = useState(false);
const navLinks = getNavLinks();
const { isFeatureVisible } = useDevDebug();
const showTopMenuButton = isFeatureVisible("nav.menu");
const openSiteMenu = useCallback(() => setMenuOpen(true), []);
const navLinks = useMemo(
() =>
getAllNavLinks().filter((link) => {
if (!link.hidden) return true;
const featureId = getNavLabelDebugFeatureId(link.labelKey);
return featureId ? isFeatureVisible(featureId) : true;
}),
[isFeatureVisible]
);
const fetchAuth = useCallback(async () => {
const session = await resolveAuthUser();
@@ -79,68 +93,94 @@ export default function NavbarComponent() {
</span>
</Link>
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
{navLinks.slice(0, 5).map((link) => (
<Link
key={link.labelKey}
href={link.href}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
isActive(link.href)
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span>{link.icon}</span>
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
</Link>
))}
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
<button
onClick={() => setMenuOpen(true)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
menuOpen
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<span className="hidden xl:inline">{tNav("menu")}</span>
</button>
</div>
{(navLinks.length > 0 || showTopMenuButton) && (
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
{navLinks.slice(0, 5).map((link) => (
<Link
key={link.labelKey}
href={link.href}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
isActive(link.href)
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span>{link.icon}</span>
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
</Link>
))}
{navLinks.length > 0 && showTopMenuButton && (
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
)}
{showTopMenuButton && (
<button
onClick={openSiteMenu}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
menuOpen
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<span className="hidden xl:inline">{tNav("menu")}</span>
</button>
)}
</div>
)}
<div className="flex items-center gap-1 sm:gap-2">
<button
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMenuOpen(true)}
>
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
{showTopMenuButton && (
<button
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={openSiteMenu}
aria-label={tNav("menu")}
>
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
)}
{userEmail ? (
<div className="flex items-center gap-2">
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
{userEmail}
</span>
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
<UserAvatar
email={userEmail}
isVip={userVip}
onLogout={handleLogout}
onOpenMenu={openSiteMenu}
/>
</div>
) : (
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
<>
<button
type="button"
onClick={openSiteMenu}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm text-gray-600 shadow-sm ring-2 ring-white transition hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-900 dark:hover:bg-gray-600"
aria-label={tNav("menu")}
title={tNav("menu")}
>
👤
</button>
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
</>
)}
<ChatNavLink />
<NotificationBell />
<button
type="button"
@@ -152,12 +192,6 @@ export default function NavbarComponent() {
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<Link
href="/join"
className="text-xs sm:text-sm bg-[#ff4d4f] text-white px-3 sm:px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium whitespace-nowrap"
>
{tNav("join")}
</Link>
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
@@ -214,7 +248,12 @@ export default function NavbarComponent() {
</button>
{userEmail ? (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { handleLogout(); setMobileOpen(false); }} />
<UserAvatar
email={userEmail}
isVip={userVip}
onLogout={() => { handleLogout(); setMobileOpen(false); }}
onOpenMenu={() => { setMobileOpen(false); openSiteMenu(); }}
/>
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
</div>
) : null}

View File

@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "@/i18n/navigation";
import { startConversation } from "@/app/lib/chat";
interface SocialGreetButtonProps {
profileId: string;
label?: string;
opener?: string;
className?: string;
onNeedLogin?: () => void;
}
export default function SocialGreetButton({
profileId,
label = "打招呼",
opener = "你好,我在同城成员里看到你,想认识一下~",
className = "rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500",
onNeedLogin,
}: SocialGreetButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
if (profileId.startsWith("fallback-")) {
router.push("/dating");
return;
}
setLoading(true);
try {
const conversation = await startConversation({ profileId, opener, intent: "friends" });
router.push(`/chat/${conversation.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("登录") && onNeedLogin) {
onNeedLogin();
}
} finally {
setLoading(false);
}
};
return (
<button type="button" onClick={handleClick} disabled={loading} className={className}>
{loading ? "连接中..." : label}
</button>
);
}

View File

@@ -14,11 +14,14 @@ interface UserAvatarProps {
email: string;
isVip?: boolean;
onLogout?: () => void;
/** 设置后点击头像打开外部菜单(如 SiteMenu不再展开内置下拉 */
onOpenMenu?: () => void;
}
/** 圆形头像 + 账户菜单 */
export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) {
export default function UserAvatar({ email, isVip = false, onLogout, onOpenMenu }: UserAvatarProps) {
const { t } = useTranslation("menu");
const { t: tNav } = useTranslation("nav");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -40,10 +43,10 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
onClick={() => (onOpenMenu ? onOpenMenu() : setOpen((o) => !o))}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white transition hover:bg-[#ff7a45] dark:ring-gray-900"
aria-label={t("yourProfile")}
aria-expanded={open}
aria-label={onOpenMenu ? tNav("menu") : t("yourProfile")}
aria-expanded={onOpenMenu ? undefined : open}
>
{getAvatarLetter(email)}
</button>
@@ -52,7 +55,7 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
VIP
</span>
)}
{open && (
{!onOpenMenu && open && (
<div className="absolute right-0 top-full z-50 mt-2 w-[260px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">{email}</p>

View File

@@ -7,6 +7,7 @@ import {
WEB_CHAT_PROVIDER_LABEL,
type MeetupSession,
} from "@/app/lib/meetups";
import MeetupSocialPanel from "@/app/components/meetups/MeetupSocialPanel";
interface EventLiveRoomProps {
locale: string;
@@ -198,6 +199,12 @@ export default function EventLiveRoom({ locale, meetup, session, formattedDate }
</section>
) : null}
</main>
<footer className="border-t border-white/10 bg-[#111827] px-4 py-5 sm:px-6">
<div className="mx-auto max-w-[1600px]">
<MeetupSocialPanel meetupId={meetup.id} city={meetup.city} locale={locale} />
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { Link } from "@/i18n/navigation";
import SocialGreetButton from "@/app/components/SocialGreetButton";
import { mediaUrl } from "@/app/lib/media";
import { fetchMeetupSocialSuggestions, type PublicProfile } from "@/app/lib/social";
interface MeetupSocialPanelProps {
meetupId: string;
city: string;
locale?: string;
compact?: boolean;
}
export default function MeetupSocialPanel({
meetupId,
city,
locale = "zh",
compact = false,
}: MeetupSocialPanelProps) {
const [members, setMembers] = useState<PublicProfile[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!meetupId) return;
fetchMeetupSocialSuggestions(meetupId)
.then((data) => setMembers(data.items || []))
.catch(() => setMembers([]))
.finally(() => setLoading(false));
}, [meetupId]);
if (loading) {
return (
<div className={`rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900 ${compact ? "" : "mt-6"}`}>
<div className="h-20 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800" />
</div>
);
}
if (!members.length) return null;
return (
<section className={`rounded-2xl border border-violet-100 bg-violet-50/60 p-4 dark:border-violet-900/40 dark:bg-violet-950/20 ${compact ? "" : "mt-6"}`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "活动后继续聊" : "Keep connecting"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{locale === "zh" ? `同城 ${city} 成员,优先展示一起报名的伙伴` : `Members in ${city}, RSVP attendees first`}
</p>
</div>
<Link
href={`/dating?intent=friends&city=${encodeURIComponent(city)}`}
className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500"
>
{locale === "zh" ? "去匹配" : "Go matching"}
</Link>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{members.slice(0, compact ? 4 : 6).map((member) => (
<article
key={member.id}
className="flex items-center gap-3 rounded-xl border border-white/80 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<Link href={`/members/${member.id}`}>
<img
src={mediaUrl(member.photo)}
alt={member.name || "member"}
className="h-11 w-11 rounded-full object-cover"
/>
</Link>
<div className="min-w-0 flex-1">
<Link href={`/members/${member.id}`} className="truncate text-sm font-semibold text-gray-900 hover:text-[#ff4d4f] dark:text-gray-100">
{member.name}
</Link>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{member.location}</p>
{"rsvpAttendee" in member && (member as PublicProfile & { rsvpAttendee?: boolean }).rsvpAttendee ? (
<span className="mt-1 inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300">
{locale === "zh" ? "同场报名" : "RSVP"}
</span>
) : null}
</div>
<SocialGreetButton
profileId={member.id}
label={locale === "zh" ? "聊" : "Hi"}
className="rounded-full bg-[#ff4d4f] px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-red-500"
/>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
DEBUG_FEATURES,
getDebugFeatureDefaultVisible,
type DebugFeature,
} from "@/config/debug-features";
const STORAGE_KEY = "nomadcna:dev-debug-overrides";
const IS_DEV = process.env.NODE_ENV === "development";
type Overrides = Record<string, boolean>;
interface DevDebugContextValue {
isDev: boolean;
panelOpen: boolean;
setPanelOpen: (open: boolean) => void;
features: DebugFeature[];
overrides: Overrides;
isFeatureVisible: (id: string) => boolean;
setFeatureVisible: (id: string, visible: boolean) => void;
resetAll: () => void;
}
const DevDebugContext = createContext<DevDebugContextValue | null>(null);
function readOverrides(): Overrides {
if (!IS_DEV || typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Overrides;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeOverrides(overrides: Overrides) {
if (!IS_DEV || typeof window === "undefined") return;
try {
if (Object.keys(overrides).length === 0) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
}
} catch {
// ignore quota / private mode
}
}
export function DevDebugProvider({ children }: { children: ReactNode }) {
const [panelOpen, setPanelOpen] = useState(false);
const [overrides, setOverrides] = useState<Overrides>({});
useEffect(() => {
setOverrides(readOverrides());
}, []);
const isFeatureVisible = useCallback(
(id: string) => {
if (!IS_DEV) return getDebugFeatureDefaultVisible(id);
if (id in overrides) return overrides[id];
return getDebugFeatureDefaultVisible(id);
},
[overrides]
);
const setFeatureVisible = useCallback((id: string, visible: boolean) => {
if (!IS_DEV) return;
setOverrides((prev) => {
const defaultVisible = getDebugFeatureDefaultVisible(id);
const next = { ...prev };
if (visible === defaultVisible) {
delete next[id];
} else {
next[id] = visible;
}
writeOverrides(next);
return next;
});
}, []);
const resetAll = useCallback(() => {
if (!IS_DEV) return;
setOverrides({});
writeOverrides({});
}, []);
const value = useMemo<DevDebugContextValue>(
() => ({
isDev: IS_DEV,
panelOpen,
setPanelOpen,
features: DEBUG_FEATURES,
overrides,
isFeatureVisible,
setFeatureVisible,
resetAll,
}),
[panelOpen, overrides, isFeatureVisible, setFeatureVisible, resetAll]
);
return <DevDebugContext.Provider value={value}>{children}</DevDebugContext.Provider>;
}
export function useDevDebug() {
const context = useContext(DevDebugContext);
if (!context) {
throw new Error("useDevDebug must be used within DevDebugProvider");
}
return context;
}

677
app/data/cities-extra.ts Normal file
View File

@@ -0,0 +1,677 @@
import type { City } from "./cities";
/** 各省补充城市id 19+),与 backend/seed_data.py 保持同步 */
export const CITIES_EXTRA: City[] = [
{
id: 19,
slug: "tianjin",
name: "天津",
nameEn: "Tianjin",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 5500,
internetSpeed: 85,
safety: "很好",
liked: "好",
temperature: 14,
humidity: 60,
nomadsNow: 210,
description: "海河之滨的港口名城,中西合璧的建筑与悠闲市井",
gradientFrom: "#64748b",
gradientTo: "#0ea5e9",
icon: "🎡",
tags: ["都市", "海滨", "文化", "美食"],
lat: 39.1,
lng: 117.2,
region: "亚洲",
},
{
id: 20,
slug: "shijiazhuang",
name: "石家庄",
nameEn: "Shijiazhuang",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.4,
costPerMonth: 3800,
internetSpeed: 65,
safety: "好",
liked: "好",
temperature: 14,
humidity: 55,
nomadsNow: 120,
description: "华北交通枢纽,低成本试住与周边古建探索的基地",
gradientFrom: "#78716c",
gradientTo: "#a16207",
icon: "🚉",
tags: ["便宜", "都市"],
lat: 38.0,
lng: 114.5,
region: "亚洲",
},
{
id: 21,
slug: "taiyuan",
name: "太原",
nameEn: "Taiyuan",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 4000,
internetSpeed: 70,
safety: "好",
liked: "好",
temperature: 12,
humidity: 50,
nomadsNow: 140,
description: "晋商故里,面食文化与低成本生活的北方之城",
gradientFrom: "#b45309",
gradientTo: "#78716c",
icon: "🍜",
tags: ["便宜", "美食", "文化"],
lat: 37.9,
lng: 112.5,
region: "亚洲",
},
{
id: 22,
slug: "hohhot",
name: "呼和浩特",
nameEn: "Hohhot",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 3600,
internetSpeed: 60,
safety: "好",
liked: "好",
temperature: 8,
humidity: 45,
nomadsNow: 95,
description: "草原边上的首府,蒙古族文化与开阔天际线",
gradientFrom: "#65a30d",
gradientTo: "#0d9488",
icon: "🐎",
tags: ["便宜", "文化", "户外运动"],
lat: 40.8,
lng: 111.7,
region: "亚洲",
},
{
id: 23,
slug: "dalian",
name: "大连",
nameEn: "Dalian",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 4800,
internetSpeed: 75,
safety: "很好",
liked: "好",
temperature: 12,
humidity: 65,
nomadsNow: 190,
description: "东北海滨明珠,广场文化与清爽海风的宜居港城",
gradientFrom: "#2563eb",
gradientTo: "#06b6d4",
icon: "⚓",
tags: ["海滨", "宜居", "都市"],
lat: 38.9,
lng: 121.6,
region: "亚洲",
},
{
id: 24,
slug: "shenyang",
name: "沈阳",
nameEn: "Shenyang",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 4200,
internetSpeed: 72,
safety: "好",
liked: "好",
temperature: 10,
humidity: 58,
nomadsNow: 160,
description: "东北重镇,工业遗产与朝鲜族美食交织的省会",
gradientFrom: "#dc2626",
gradientTo: "#78716c",
icon: "🏭",
tags: ["便宜", "美食", "都市"],
lat: 41.8,
lng: 123.4,
region: "亚洲",
},
{
id: 25,
slug: "changchun",
name: "长春",
nameEn: "Changchun",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.4,
costPerMonth: 3900,
internetSpeed: 68,
safety: "好",
liked: "好",
temperature: 6,
humidity: 55,
nomadsNow: 130,
description: "汽车之城与电影之都,冬季雪景与低成本长住",
gradientFrom: "#6366f1",
gradientTo: "#94a3b8",
icon: "🎬",
tags: ["便宜", "文化"],
lat: 43.9,
lng: 125.3,
region: "亚洲",
},
{
id: 26,
slug: "harbin",
name: "哈尔滨",
nameEn: "Harbin",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 3700,
internetSpeed: 65,
safety: "好",
liked: "好",
temperature: 2,
humidity: 60,
nomadsNow: 175,
description: "东方莫斯科,冰雪艺术与俄式风情的北国名城",
gradientFrom: "#0ea5e9",
gradientTo: "#6366f1",
icon: "❄️",
tags: ["便宜", "文化", "历史名城"],
lat: 45.8,
lng: 126.5,
region: "亚洲",
},
{
id: 27,
slug: "wuxi",
name: "无锡",
nameEn: "Wuxi",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 5200,
internetSpeed: 82,
safety: "很好",
liked: "好",
temperature: 17,
humidity: 72,
nomadsNow: 155,
description: "太湖明珠,物联网产业与江南水巷的精致生活",
gradientFrom: "#0d9488",
gradientTo: "#2563eb",
icon: "🌉",
tags: ["宜居", "高速网络", "文化"],
lat: 31.5,
lng: 120.3,
region: "亚洲",
},
{
id: 28,
slug: "ningbo",
name: "宁波",
nameEn: "Ningbo",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 5500,
internetSpeed: 88,
safety: "很好",
liked: "好",
temperature: 18,
humidity: 75,
nomadsNow: 200,
description: "港口名城,外贸基因与海鲜美食的东海之滨",
gradientFrom: "#1d4ed8",
gradientTo: "#0891b2",
icon: "🚢",
tags: ["海滨", "美食", "都市"],
lat: 29.9,
lng: 121.5,
region: "亚洲",
},
{
id: 29,
slug: "hefei",
name: "合肥",
nameEn: "Hefei",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4500,
internetSpeed: 80,
safety: "很好",
liked: "好",
temperature: 16,
humidity: 70,
nomadsNow: 185,
description: "科教之城,科大讯飞与新兴科技产业的崛起之地",
gradientFrom: "#7c3aed",
gradientTo: "#2563eb",
icon: "🔬",
tags: ["高速网络", "都市", "新一线"],
lat: 31.8,
lng: 117.2,
region: "亚洲",
},
{
id: 30,
slug: "fuzhou",
name: "福州",
nameEn: "Fuzhou",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 4800,
internetSpeed: 78,
safety: "很好",
liked: "好",
temperature: 20,
humidity: 78,
nomadsNow: 170,
description: "榕城温泉故里,闽菜发源地与温润的东南生活",
gradientFrom: "#059669",
gradientTo: "#0d9488",
icon: "🌳",
tags: ["温暖", "美食", "宜居"],
lat: 26.1,
lng: 119.3,
region: "亚洲",
},
{
id: 31,
slug: "nanchang",
name: "南昌",
nameEn: "Nanchang",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 4000,
internetSpeed: 70,
safety: "好",
liked: "好",
temperature: 18,
humidity: 75,
nomadsNow: 145,
description: "英雄之城,赣江两岸的红色记忆与低成本省会",
gradientFrom: "#dc2626",
gradientTo: "#ea580c",
icon: "🏮",
tags: ["便宜", "文化", "历史名城"],
lat: 28.7,
lng: 115.9,
region: "亚洲",
},
{
id: 32,
slug: "jinan",
name: "济南",
nameEn: "Jinan",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 4600,
internetSpeed: 75,
safety: "很好",
liked: "好",
temperature: 15,
humidity: 62,
nomadsNow: 165,
description: "泉城济南,趵突泉畔的齐鲁文化与温润气候",
gradientFrom: "#0284c7",
gradientTo: "#059669",
icon: "⛲",
tags: ["文化", "宜居", "历史名城"],
lat: 36.7,
lng: 117.0,
region: "亚洲",
},
{
id: 33,
slug: "zhengzhou",
name: "郑州",
nameEn: "Zhengzhou",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 4400,
internetSpeed: 78,
safety: "很好",
liked: "好",
temperature: 15,
humidity: 60,
nomadsNow: 220,
description: "中原枢纽,高铁十字与河南面食的烟火省会",
gradientFrom: "#f59e0b",
gradientTo: "#78716c",
icon: "🚄",
tags: ["美食", "都市", "新一线"],
lat: 34.7,
lng: 113.6,
region: "亚洲",
},
{
id: 34,
slug: "wuhan",
name: "武汉",
nameEn: "Wuhan",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 4800,
internetSpeed: 85,
safety: "很好",
liked: "好",
temperature: 17,
humidity: 72,
nomadsNow: 310,
description: "九省通衢,长江与汉江交汇的科教重镇",
gradientFrom: "#f97316",
gradientTo: "#2563eb",
icon: "🌉",
tags: ["美食", "都市", "新一线", "文化"],
lat: 30.6,
lng: 114.3,
region: "亚洲",
},
{
id: 35,
slug: "guilin",
name: "桂林",
nameEn: "Guilin",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 3500,
internetSpeed: 55,
safety: "好",
liked: "很好",
temperature: 19,
humidity: 75,
nomadsNow: 240,
description: "山水甲天下,漓江喀斯特地貌间的慢游胜地",
gradientFrom: "#059669",
gradientTo: "#0d9488",
icon: "⛰️",
tags: ["便宜", "宜居", "户外运动", "文化"],
lat: 25.3,
lng: 110.3,
region: "亚洲",
},
{
id: 36,
slug: "nanning",
name: "南宁",
nameEn: "Nanning",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 3800,
internetSpeed: 68,
safety: "好",
liked: "好",
temperature: 22,
humidity: 80,
nomadsNow: 150,
description: "绿城南宁,东盟门户与热带水果的温润首府",
gradientFrom: "#22c55e",
gradientTo: "#0d9488",
icon: "🌴",
tags: ["温暖", "便宜", "美食"],
lat: 22.8,
lng: 108.3,
region: "亚洲",
},
{
id: 37,
slug: "haikou",
name: "海口",
nameEn: "Haikou",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4200,
internetSpeed: 62,
safety: "好",
liked: "好",
temperature: 25,
humidity: 82,
nomadsNow: 195,
description: "椰城海口,自贸港政策与热带海岛生活的入口",
gradientFrom: "#0ea5e9",
gradientTo: "#22c55e",
icon: "🥥",
tags: ["温暖", "海滨", "宜居"],
lat: 20.0,
lng: 110.3,
region: "亚洲",
},
{
id: 38,
slug: "guiyang",
name: "贵阳",
nameEn: "Guiyang",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 3600,
internetSpeed: 72,
safety: "好",
liked: "好",
temperature: 16,
humidity: 78,
nomadsNow: 180,
description: "爽爽贵阳,大数据产业与喀斯特山城的清凉夏日",
gradientFrom: "#6366f1",
gradientTo: "#059669",
icon: "☁️",
tags: ["便宜", "高速网络", "山城"],
lat: 26.6,
lng: 106.7,
region: "亚洲",
},
{
id: 39,
slug: "lhasa",
name: "拉萨",
nameEn: "Lhasa",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 4000,
internetSpeed: 50,
safety: "好",
liked: "好",
temperature: 10,
humidity: 35,
nomadsNow: 85,
description: "日光之城,布达拉宫下的藏文化与高原静修",
gradientFrom: "#eab308",
gradientTo: "#dc2626",
icon: "🙏",
tags: ["文化", "山城", "历史名城"],
lat: 29.6,
lng: 91.1,
region: "亚洲",
},
{
id: 40,
slug: "lanzhou",
name: "兰州",
nameEn: "Lanzhou",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.4,
costPerMonth: 3500,
internetSpeed: 60,
safety: "好",
liked: "好",
temperature: 12,
humidity: 48,
nomadsNow: 110,
description: "黄河穿城而过,牛肉面与丝绸之路的西北枢纽",
gradientFrom: "#ca8a04",
gradientTo: "#78716c",
icon: "🍜",
tags: ["便宜", "美食", "文化"],
lat: 36.1,
lng: 103.8,
region: "亚洲",
},
{
id: 41,
slug: "xining",
name: "西宁",
nameEn: "Xining",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 3400,
internetSpeed: 58,
safety: "好",
liked: "好",
temperature: 8,
humidity: 50,
nomadsNow: 90,
description: "高原东门,青海湖门户与多元民族文化的交汇",
gradientFrom: "#0ea5e9",
gradientTo: "#65a30d",
icon: "🏔️",
tags: ["便宜", "户外运动", "文化"],
lat: 36.6,
lng: 101.8,
region: "亚洲",
},
{
id: 42,
slug: "yinchuan",
name: "银川",
nameEn: "Yinchuan",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 3600,
internetSpeed: 62,
safety: "好",
liked: "好",
temperature: 11,
humidity: 45,
nomadsNow: 100,
description: "塞上江南,西夏文化与贺兰山葡萄酒的宁静首府",
gradientFrom: "#a16207",
gradientTo: "#65a30d",
icon: "🍇",
tags: ["便宜", "文化", "宜居"],
lat: 38.5,
lng: 106.2,
region: "亚洲",
},
{
id: 43,
slug: "urumqi",
name: "乌鲁木齐",
nameEn: "Urumqi",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 3800,
internetSpeed: 65,
safety: "好",
liked: "好",
temperature: 8,
humidity: 40,
nomadsNow: 125,
description: "亚欧大陆桥中心,多元文化与天山脚下的边疆都市",
gradientFrom: "#dc2626",
gradientTo: "#f59e0b",
icon: "🕌",
tags: ["文化", "美食", "都市"],
lat: 43.8,
lng: 87.6,
region: "亚洲",
},
{
id: 44,
slug: "hong-kong",
name: "香港",
nameEn: "Hong Kong",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 12000,
internetSpeed: 150,
safety: "极好",
liked: "好",
temperature: 24,
humidity: 80,
nomadsNow: 420,
description: "东方之珠,国际金融中心与山海同框的紧凑都市",
gradientFrom: "#1d4ed8",
gradientTo: "#dc2626",
icon: "🌃",
tags: ["高速网络", "都市", "海滨", "安全"],
lat: 22.3,
lng: 114.2,
region: "亚洲",
},
{
id: 45,
slug: "macau",
name: "澳门",
nameEn: "Macau",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 9000,
internetSpeed: 120,
safety: "极好",
liked: "好",
temperature: 23,
humidity: 78,
nomadsNow: 160,
description: "中西合璧的博彩旅游城,葡式风情与紧凑步行生活",
gradientFrom: "#eab308",
gradientTo: "#059669",
icon: "🎰",
tags: ["文化", "美食", "海滨"],
lat: 22.2,
lng: 113.5,
region: "亚洲",
},
{
id: 46,
slug: "taipei",
name: "台北",
nameEn: "Taipei",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.1,
costPerMonth: 7500,
internetSpeed: 130,
safety: "极好",
liked: "很好",
temperature: 22,
humidity: 75,
nomadsNow: 380,
description: "宝岛之都,夜市文化、捷运生活与友善的创客社群",
gradientFrom: "#0ea5e9",
gradientTo: "#6366f1",
icon: "🧋",
tags: ["美食", "高速网络", "都市", "友好社区"],
lat: 25.0,
lng: 121.5,
region: "亚洲",
},
];

View File

@@ -1,3 +1,5 @@
import { CITIES_EXTRA } from "./cities-extra";
export interface City {
id: number;
idNumber?: number;
@@ -421,6 +423,7 @@ export const cities: City[] = [
icon: "🎡",
tags: ["海滨", "温暖", "宜居", "安全"],
},
...CITIES_EXTRA,
];
export const allTags = [

View File

@@ -31,6 +31,34 @@ export const MAP_CITIES: MapCity[] = [
{ 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 },
{ id: 19, name: "天津", nameEn: "Tianjin", emoji: "🎡", lat: 39.1, lng: 117.2, nomadsNow: 210 },
{ id: 20, name: "石家庄", nameEn: "Shijiazhuang", emoji: "🚉", lat: 38.0, lng: 114.5, nomadsNow: 120 },
{ id: 21, name: "太原", nameEn: "Taiyuan", emoji: "🍜", lat: 37.9, lng: 112.5, nomadsNow: 140 },
{ id: 22, name: "呼和浩特", nameEn: "Hohhot", emoji: "🐎", lat: 40.8, lng: 111.7, nomadsNow: 95 },
{ id: 23, name: "大连", nameEn: "Dalian", emoji: "⚓", lat: 38.9, lng: 121.6, nomadsNow: 190 },
{ id: 24, name: "沈阳", nameEn: "Shenyang", emoji: "🏭", lat: 41.8, lng: 123.4, nomadsNow: 160 },
{ id: 25, name: "长春", nameEn: "Changchun", emoji: "🎬", lat: 43.9, lng: 125.3, nomadsNow: 130 },
{ id: 26, name: "哈尔滨", nameEn: "Harbin", emoji: "❄️", lat: 45.8, lng: 126.5, nomadsNow: 175 },
{ id: 27, name: "无锡", nameEn: "Wuxi", emoji: "🌉", lat: 31.5, lng: 120.3, nomadsNow: 155 },
{ id: 28, name: "宁波", nameEn: "Ningbo", emoji: "🚢", lat: 29.9, lng: 121.5, nomadsNow: 200 },
{ id: 29, name: "合肥", nameEn: "Hefei", emoji: "🔬", lat: 31.8, lng: 117.2, nomadsNow: 185 },
{ id: 30, name: "福州", nameEn: "Fuzhou", emoji: "🌳", lat: 26.1, lng: 119.3, nomadsNow: 170 },
{ id: 31, name: "南昌", nameEn: "Nanchang", emoji: "🏮", lat: 28.7, lng: 115.9, nomadsNow: 145 },
{ id: 32, name: "济南", nameEn: "Jinan", emoji: "⛲", lat: 36.7, lng: 117.0, nomadsNow: 165 },
{ id: 33, name: "郑州", nameEn: "Zhengzhou", emoji: "🚄", lat: 34.7, lng: 113.6, nomadsNow: 220 },
{ id: 34, name: "武汉", nameEn: "Wuhan", emoji: "🌉", lat: 30.6, lng: 114.3, nomadsNow: 310 },
{ id: 35, name: "桂林", nameEn: "Guilin", emoji: "⛰️", lat: 25.3, lng: 110.3, nomadsNow: 240 },
{ id: 36, name: "南宁", nameEn: "Nanning", emoji: "🌴", lat: 22.8, lng: 108.3, nomadsNow: 150 },
{ id: 37, name: "海口", nameEn: "Haikou", emoji: "🥥", lat: 20.0, lng: 110.3, nomadsNow: 195 },
{ id: 38, name: "贵阳", nameEn: "Guiyang", emoji: "☁️", lat: 26.6, lng: 106.7, nomadsNow: 180 },
{ id: 39, name: "拉萨", nameEn: "Lhasa", emoji: "🙏", lat: 29.6, lng: 91.1, nomadsNow: 85 },
{ id: 40, name: "兰州", nameEn: "Lanzhou", emoji: "🍜", lat: 36.1, lng: 103.8, nomadsNow: 110 },
{ id: 41, name: "西宁", nameEn: "Xining", emoji: "🏔️", lat: 36.6, lng: 101.8, nomadsNow: 90 },
{ id: 42, name: "银川", nameEn: "Yinchuan", emoji: "🍇", lat: 38.5, lng: 106.2, nomadsNow: 100 },
{ id: 43, name: "乌鲁木齐", nameEn: "Urumqi", emoji: "🕌", lat: 43.8, lng: 87.6, nomadsNow: 125 },
{ id: 44, name: "香港", nameEn: "Hong Kong", emoji: "🌃", lat: 22.3, lng: 114.2, nomadsNow: 420 },
{ id: 45, name: "澳门", nameEn: "Macau", emoji: "🎰", lat: 22.2, lng: 113.5, nomadsNow: 160 },
{ id: 46, name: "台北", nameEn: "Taipei", emoji: "🧋", lat: 25.0, lng: 121.5, nomadsNow: 380 },
];
export const MAP_BOUNDS = {

View File

@@ -79,3 +79,16 @@ export async function sendChatMessage(conversationId: string, body: string): Pro
export async function markChatRead(conversationId: string): Promise<void> {
await apiFetch(`/api/conversations/${conversationId}/read`, { method: "POST", body: JSON.stringify({}) });
}
export async function startConversation(options: {
profileId?: string;
targetUserId?: string;
intent?: string;
opener?: string;
}): Promise<ConversationItem> {
const data = await apiFetch<{ conversation: ConversationItem }>("/api/conversations/start", {
method: "POST",
body: JSON.stringify(options),
});
return data.conversation;
}

View File

@@ -19,6 +19,34 @@ const CITY_SLUG_BY_NAME: Record<string, string> = {
: "qingdao",
: "suzhou",
: "zhuhai",
: "tianjin",
: "shijiazhuang",
: "taiyuan",
: "hohhot",
: "dalian",
: "shenyang",
: "changchun",
: "harbin",
: "wuxi",
: "ningbo",
: "hefei",
: "fuzhou",
: "nanchang",
: "jinan",
: "zhengzhou",
: "wuhan",
: "guilin",
: "nanning",
: "haikou",
: "guiyang",
: "lhasa",
: "lanzhou",
西: "xining",
: "yinchuan",
: "urumqi",
: "hong-kong",
: "macau",
: "taipei",
};
function normalizeSlug(value: string): string {

64
app/lib/city-weather.ts Normal file
View File

@@ -0,0 +1,64 @@
export interface CityWeatherLive {
temperature: number;
humidity?: number;
apparentTemperature?: number;
weatherCode?: number;
observedAt?: string;
}
export type CityWeatherMap = Record<string, CityWeatherLive>;
/** WMO weather code → 简短展示 */
export function weatherCodeLabel(code?: number, locale: "zh" | "en" = "zh"): string | null {
if (code == null) return null;
const zh: Record<number, string> = {
0: "晴",
1: "多云",
2: "多云",
3: "阴",
45: "雾",
48: "雾",
51: "小雨",
53: "小雨",
55: "小雨",
61: "雨",
63: "雨",
65: "大雨",
71: "雪",
73: "雪",
75: "大雪",
80: "阵雨",
81: "阵雨",
82: "暴雨",
95: "雷雨",
};
const en: Record<number, string> = {
0: "Clear",
1: "Cloudy",
2: "Cloudy",
3: "Overcast",
45: "Fog",
48: "Fog",
51: "Drizzle",
53: "Drizzle",
55: "Drizzle",
61: "Rain",
63: "Rain",
65: "Heavy rain",
71: "Snow",
73: "Snow",
75: "Heavy snow",
80: "Showers",
81: "Showers",
82: "Storms",
95: "Thunder",
};
const table = locale === "zh" ? zh : en;
return table[code] ?? (locale === "zh" ? "天气" : "Weather");
}
export function cityWeatherKey(city: { slug?: string; id?: number; name?: string }): string {
if (city.slug) return city.slug;
if (city.id != null) return String(city.id);
return city.name || "";
}

View File

@@ -134,7 +134,7 @@ export function getLoungeServerLabel(): string {
}
export function buildMeetupChatUrl(meetup: MeetupLike): string {
const channel = createLoungeChannel(meetup);
const channel = createLoungeChannel(meetup).replace(/^#+/, "");
return `${getLoungeBaseUrl()}/#/chan-${channel}`;
}

View File

@@ -114,7 +114,6 @@ export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
attempts += 1;
if (attempts > MAX_POLLS) {
stop();
clearPendingOrder();
setPolling(false);
return;
}

97
app/lib/social.ts Normal file
View File

@@ -0,0 +1,97 @@
import { apiFetch } from "@/app/lib/api-client";
export interface PublicProfile {
id: string;
userId?: string;
name?: string;
age?: number;
location?: string;
citySlug?: string;
gender?: string;
single?: string;
tags?: string[];
bio?: string;
photo?: string;
lookingFor?: string[];
}
export interface DiscussionReply {
id: string;
discussionId?: string;
userId?: string;
author?: string;
body: string;
created?: string;
}
export async function fetchPublicProfile(profileId: string): Promise<PublicProfile | null> {
try {
const data = await apiFetch<{ profile?: PublicProfile }>(`/api/profiles/${profileId}`);
return data.profile || null;
} catch {
return null;
}
}
export async function rsvpMeetup(meetupId: string): Promise<{ rsvp: boolean; meetup?: Record<string, unknown> }> {
return apiFetch(`/api/meetups/${meetupId}/rsvp`, { method: "POST", body: JSON.stringify({}) });
}
export async function cancelMeetupRsvp(meetupId: string): Promise<{ rsvp: boolean; meetup?: Record<string, unknown> }> {
return apiFetch(`/api/meetups/${meetupId}/rsvp`, { method: "DELETE" });
}
export async function fetchMeetupRsvpStatus(meetupId: string): Promise<boolean> {
try {
const data = await apiFetch<{ rsvp?: boolean }>(`/api/meetups/${meetupId}/rsvp`);
return Boolean(data.rsvp);
} catch {
return false;
}
}
export async function fetchDiscussion(discussionId: string) {
return apiFetch<{ item: Record<string, unknown> }>(`/api/discussions/${discussionId}`);
}
export async function fetchDiscussionReplies(discussionId: string): Promise<DiscussionReply[]> {
const data = await apiFetch<{ items: DiscussionReply[] }>(`/api/discussions/${discussionId}/replies`);
return data.items || [];
}
export async function postDiscussionReply(discussionId: string, body: string): Promise<DiscussionReply> {
const data = await apiFetch<{ reply: DiscussionReply }>(`/api/discussions/${discussionId}/replies`, {
method: "POST",
body: JSON.stringify({ body }),
});
return data.reply;
}
export async function toggleDiscussionLike(
discussionId: string
): Promise<{ liked: boolean; likes: number; item?: Record<string, unknown> }> {
return apiFetch(`/api/discussions/${discussionId}/like`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function toggleDiscussionPin(
discussionId: string
): Promise<{ pinned: boolean; item?: Record<string, unknown> }> {
return apiFetch(`/api/discussions/${discussionId}/pin`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchMeetupSocialSuggestions(meetupId: string): Promise<{
items: Array<PublicProfile & { rsvpAttendee?: boolean }>;
city: string;
}> {
const data = await apiFetch<{
items: Array<PublicProfile & { rsvpAttendee?: boolean }>;
city: string;
}>(`/api/meetups/${meetupId}/social-suggestions`);
return { items: data.items || [], city: data.city || "" };
}