's'
This commit is contained in:
@@ -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
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
268
app/[locale]/discuss/[id]/page.tsx
Normal file
268
app/[locale]/discuss/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
🌍 数字游民社区报名
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
141
app/[locale]/members/[id]/page.tsx
Normal file
141
app/[locale]/members/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
63
app/components/ChatNavLink.tsx
Normal file
63
app/components/ChatNavLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
154
app/components/DevDebugFab.tsx
Normal file
154
app/components/DevDebugFab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
app/components/DevDebugTools.tsx
Normal file
14
app/components/DevDebugTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
49
app/components/SocialGreetButton.tsx
Normal file
49
app/components/SocialGreetButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
95
app/components/meetups/MeetupSocialPanel.tsx
Normal file
95
app/components/meetups/MeetupSocialPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
app/context/DevDebugContext.tsx
Normal file
122
app/context/DevDebugContext.tsx
Normal 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
677
app/data/cities-extra.ts
Normal 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: "亚洲",
|
||||
},
|
||||
];
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
64
app/lib/city-weather.ts
Normal 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 || "";
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
97
app/lib/social.ts
Normal 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 || "" };
|
||||
}
|
||||
Reference in New Issue
Block a user