nomadweb/frontend/src/components/GlobalSearch.tsx
eric ff7b721acb feat: NomadCNA community layer, social matching, payments, and ebook
Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:38:13 -05:00

174 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { api } from "@/lib/api";
import { TOOL_LINKS } from "@/lib/tools";
import type { SearchResult } from "@/lib/types";
const TYPE_LABELS: Record<string, string> = {
destination: "目的地",
blog: "博客",
visa: "签证",
faq: "FAQ",
tool: "工具",
page: "页面",
};
const PAGE_RESULTS: SearchResult[] = [
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
{ type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" },
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" },
{ type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" },
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
];
function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
const s = q.toLowerCase();
const tools = TOOL_LINKS
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
.map((t) => ({
type: "tool",
title: t.title,
subtitle: t.desc,
emoji: t.emoji,
url: t.href,
}));
const pages = PAGE_RESULTS.filter(
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
).filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
return [...tools, ...pages];
}
export default function GlobalSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const router = useRouter();
const isHome = usePathname() === "/";
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((o) => !o);
}
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
const local = searchLocal(q, isHome);
try {
const data = await api.search(q);
const merged = [...local, ...data].slice(0, 20);
setResults(merged);
setActiveIdx(0);
} catch {
setResults(local);
setActiveIdx(0);
} finally {
setLoading(false);
}
}, [isHome]);
useEffect(() => {
const timer = setTimeout(() => doSearch(query), 250);
return () => clearTimeout(timer);
}, [query, doSearch]);
const navigate = (url: string) => {
setOpen(false);
setQuery("");
if (url.startsWith("/#")) {
router.push("/");
setTimeout(() => {
document.querySelector(url.replace("/", ""))?.scrollIntoView({ behavior: "smooth" });
}, 300);
} else {
router.push(url);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
if (e.key === "Enter" && results[activeIdx]) navigate(results[activeIdx].url);
};
return (
<>
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
<span>搜索...</span>
<kbd>⌘K</kbd>
</button>
{open && (
<div className="search-overlay" onClick={() => setOpen(false)}>
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
<div className="search-input-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
<input
autoFocus
placeholder="搜索目的地、工具、签证、博客…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
<button onClick={() => setOpen(false)}>ESC</button>
</div>
<div className="search-results">
{loading && <p className="search-empty">搜索中...</p>}
{!loading && query && results.length === 0 && (
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
)}
{!loading && results.map((r, i) => (
<button key={`${r.type}-${r.url}-${r.title}`}
className={`search-result${i === activeIdx ? " active" : ""}`}
onClick={() => navigate(r.url)}
onMouseEnter={() => setActiveIdx(i)}>
<span className="search-result-emoji">{r.emoji}</span>
<div className="search-result-text">
<strong>{r.title}</strong>
<span>{r.subtitle}</span>
</div>
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
</button>
))}
{!query && (
<div className="search-hints">
<p>💡 试试:清迈、机票、税居、工具箱</p>
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}