From 969a0b037a6c16571d120e2b379d6005322b1fef Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 19:06:32 -0500 Subject: [PATCH] Continue progressive rings: search, onboard, plan hubs. Empty search shows three-ring shortcuts, onboarding follows discover-belong-grow, and plan/compare/next-stop surface only the next linked steps. Co-authored-by: Cursor --- frontend/src/app/globals.css | 50 ++++++++++ frontend/src/components/CompareClient.tsx | 5 + frontend/src/components/Destinations.tsx | 2 +- frontend/src/components/GlobalSearch.tsx | 108 +++++++++++++++------ frontend/src/components/MovePlanClient.tsx | 10 ++ frontend/src/components/NextStopClient.tsx | 16 ++- frontend/src/components/OnboardingTour.tsx | 35 +++++-- 7 files changed, 186 insertions(+), 40 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ab2981f..48124cc 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10916,3 +10916,53 @@ img { max-width: 100%; display: block; } grid-template-columns: 1fr; } } + +.search-ring-hints { + padding: 8px 4px 12px; +} +.search-ring-label { + font-size: 0.8rem; + letter-spacing: 0.06em; + color: var(--text-muted); + margin: 0 0 12px; +} +.search-ring-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.search-ring-chip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 12px; + border-radius: var(--radius-md); + border: var(--border-glass); + background: var(--bg-glass); + color: var(--text-primary); + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, transform 0.2s ease; +} +.search-ring-chip:hover { + border-color: rgba(78, 205, 196, 0.4); + transform: translateY(-1px); +} +.search-ring-chip span { + font-size: 1.2rem; +} +.search-ring-chip strong { + font-size: 0.85rem; + font-weight: 600; +} +.search-hints-kbd { + margin: 14px 0 0; + font-size: 0.78rem; + color: var(--text-muted); +} +@media (max-width: 640px) { + .search-ring-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/frontend/src/components/CompareClient.tsx b/frontend/src/components/CompareClient.tsx index 0fdfb9e..5628414 100644 --- a/frontend/src/components/CompareClient.tsx +++ b/frontend/src/components/CompareClient.tsx @@ -14,6 +14,8 @@ import { import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { useI18n } from "@/lib/i18n"; import type { Destination } from "@/lib/types"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; interface Props { destinations: Destination[]; @@ -24,6 +26,7 @@ export default function CompareClient({ destinations }: Props) { const router = useRouter(); const { toast } = useToast(); const { t } = useI18n(); + const rings = useRingSteps(); const [picked, setPicked] = useState([]); useEffect(() => { @@ -224,6 +227,8 @@ export default function CompareClient({ destinations }: Props) { )} + + ); diff --git a/frontend/src/components/Destinations.tsx b/frontend/src/components/Destinations.tsx index 01ab492..a658a5b 100644 --- a/frontend/src/components/Destinations.tsx +++ b/frontend/src/components/Destinations.tsx @@ -6,7 +6,7 @@ import { useI18n } from "@/lib/i18n"; import type { Destination } from "@/lib/types"; import FavoriteButton from "./FavoriteButton"; -const INITIAL = 8; +const INITIAL = 6; interface Props { destinations: Destination[]; diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx index 9f9adc7..62f0f25 100644 --- a/frontend/src/components/GlobalSearch.tsx +++ b/frontend/src/components/GlobalSearch.tsx @@ -15,7 +15,17 @@ const TYPE_LABELS: Record = { page: "页面", }; -const PAGE_RESULTS: SearchResult[] = [ +/** Empty-state shortcuts — three rings only, not the whole sitemap. */ +const RING_SHORTCUTS: SearchResult[] = [ + { type: "page", title: "智能发现", subtitle: "匹配偏好,锁定下一座城市", emoji: "🎯", url: "/#path" }, + { 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: "/digital" }, + { type: "page", title: "实用工具", subtitle: "按需取用,不堆菜单", emoji: "🛠️", url: "/tools" }, +]; + +const CORE_PAGES: SearchResult[] = [ { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" }, { type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" }, { type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" }, @@ -25,6 +35,12 @@ const PAGE_RESULTS: SearchResult[] = [ { 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: "旅居小工具按需取用", emoji: "🛠️", url: "/tools" }, + { type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" }, +]; + +/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */ +const EXTRA_PAGES: SearchResult[] = [ { type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" }, { type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" }, { type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" }, @@ -36,8 +52,6 @@ const PAGE_RESULTS: SearchResult[] = [ { type: "page", title: "联系我们", subtitle: "合作与媒体", emoji: "📬", url: "/contact" }, { 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" }, @@ -48,16 +62,22 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] { 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")) + .slice(0, 8) .map((t) => ({ - type: "tool", + type: "tool" as const, 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")); + + const matchPage = (p: SearchResult) => + p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s); + + const pages = [...CORE_PAGES, ...EXTRA_PAGES] + .filter(matchPage) + .filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare")); + return [...tools, ...pages]; } @@ -83,16 +103,19 @@ export default function GlobalSearch() { }, []); const doSearch = useCallback(async (q: string) => { - if (!q.trim()) { setResults([]); return; } + 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); + const merged = [...local, ...data].slice(0, 16); setResults(merged); setActiveIdx(0); } catch { - setResults(local); + setResults(local.slice(0, 16)); setActiveIdx(0); } finally { setLoading(false); @@ -118,16 +141,27 @@ export default function GlobalSearch() { }; 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 === "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); }; + const emptyShortcuts = isHome + ? RING_SHORTCUTS.filter((p) => p.url !== "/plan" && p.url !== "/compare") + : RING_SHORTCUTS; + return ( <> - ))} + {!loading && + results.map((r, i) => ( + + ))} {!query && ( -
-

💡 试试:清迈、机票、税居、工具箱

-

⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭

+
+

从一条路径开始

+
+ {emptyShortcuts.map((p) => ( + + ))} +
+

⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭

)}
diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx index 4d6e359..fe44c3b 100644 --- a/frontend/src/components/MovePlanClient.tsx +++ b/frontend/src/components/MovePlanClient.tsx @@ -17,6 +17,8 @@ import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync"; import { buildPlanIcs, downloadPlanIcs } from "@/lib/planIcs"; import { useI18n } from "@/lib/i18n"; import type { Destination, TripItem, Visa } from "@/lib/types"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; interface Props { destinations: Destination[]; @@ -37,6 +39,7 @@ function scoreDest(d: Destination) { export default function MovePlanClient({ destinations, visas = [] }: Props) { const { toast } = useToast(); const { t } = useI18n(); + const rings = useRingSteps(); const { token, user, planSyncStatus } = useAuth(); const [trip, setTrip] = useState([]); const [meta, setMeta] = useState(() => ({ @@ -605,6 +608,13 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) { + + ); diff --git a/frontend/src/components/NextStopClient.tsx b/frontend/src/components/NextStopClient.tsx index e99b64d..443c64c 100644 --- a/frontend/src/components/NextStopClient.tsx +++ b/frontend/src/components/NextStopClient.tsx @@ -7,8 +7,11 @@ import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { useToast } from "@/lib/toast"; import { useI18n } from "@/lib/i18n"; import type { Destination, Meetup, NomadRoute, RecommendedDestination } from "@/lib/types"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; -const TAGS = ["低成本", "高速网络", "温暖气候", "社区活跃", "签证友好", "海滨", "美食"]; +const PRIMARY_TAGS = ["低成本", "高速网络", "温暖气候", "社区活跃"]; +const EXTRA_TAGS = ["签证友好", "海滨", "美食"]; const CLIMATE_OPTIONS = [ { value: "mild", label: "🌤️ 温和" }, { value: "warm", label: "☀️ 偏暖" }, @@ -38,10 +41,12 @@ function fallbackScore(d: Destination, budget: number, internet: number): Recomm export default function NextStopClient({ destinations, initialMeetups, initialRoutes }: Props) { const { t } = useI18n(); const { toast } = useToast(); + const rings = useRingSteps(); const [budget, setBudget] = useState(8000); const [internet, setInternet] = useState(50); const [climate, setClimate] = useState("mild"); const [selectedTags, setSelectedTags] = useState(["社区活跃"]); + const [moreTags, setMoreTags] = useState(false); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [meetups] = useState(initialMeetups); @@ -157,7 +162,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
- {TAGS.map((tag) => ( + {(moreTags ? [...PRIMARY_TAGS, ...EXTRA_TAGS] : PRIMARY_TAGS).map((tag) => ( + )}
@@ -290,6 +300,8 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo )} + + ); diff --git a/frontend/src/components/OnboardingTour.tsx b/frontend/src/components/OnboardingTour.tsx index 4cd0b88..6b59157 100644 --- a/frontend/src/components/OnboardingTour.tsx +++ b/frontend/src/components/OnboardingTour.tsx @@ -2,11 +2,31 @@ import { useEffect, useState } from "react"; +/** Align onboarding with the three product rings — never dump the full catalog. */ const STEPS = [ - { id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏感兴趣的目的地", action: "scroll" as const }, - { id: "matcher", emoji: "🎯", title: "智能匹配", desc: "回答几个问题,获得推荐城市", action: "matcher" as const }, - { id: "visa", emoji: "🛂", title: "签证指南", desc: "查看热门游民签证与停留政策", action: "scroll" as const }, - { id: "tools", emoji: "🧰", title: "工具箱", desc: "费用、时差、打包等实用小工具", action: "route" as const, href: "/tools" }, + { + id: "path", + emoji: "🌍", + title: "发现去哪", + desc: "用智能匹配或地图,先锁定下一座城市", + action: "matcher" as const, + }, + { + id: "meetups", + emoji: "🤝", + title: "遇见同行", + desc: "同城活动与社区讨论,先认识真实的人", + action: "route" as const, + href: "/meetups", + }, + { + id: "digital", + emoji: "🚀", + title: "成长变现", + desc: "学院与赏金任务,在路上继续做事", + action: "route" as const, + href: "/digital", + }, ]; export default function OnboardingTour() { @@ -46,10 +66,13 @@ export default function OnboardingTour() { if (!open) return null; const current = STEPS[step]; + const isLast = step >= STEPS.length - 1; return (
- +
{current.emoji} {current.title} @@ -60,7 +83,7 @@ export default function OnboardingTour() { ))}