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 <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 19:06:32 -05:00
parent 775f90dc6d
commit 969a0b037a
7 changed files with 186 additions and 40 deletions

View File

@ -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));
}
}

View File

@ -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<string[]>([]);
useEffect(() => {
@ -224,6 +227,8 @@ export default function CompareClient({ destinations }: Props) {
</svg>
</>
)}
<RingNext steps={rings.afterCity} />
</div>
</div>
);

View File

@ -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[];

View File

@ -15,7 +15,17 @@ const TYPE_LABELS: Record<string, string> = {
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 (
<>
<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" />
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span>搜索...</span>
<kbd>⌘K</kbd>
@ -138,11 +172,12 @@ export default function GlobalSearch() {
<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" />
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
autoFocus
placeholder="搜索目的地、工具、签证、博客…"
placeholder="搜城市、活动、工具… 或从下方路径进入"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
@ -154,11 +189,14 @@ export default function GlobalSearch() {
{!loading && query && results.length === 0 && (
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
)}
{!loading && results.map((r, i) => (
<button key={`${r.type}-${r.url}-${r.title}`}
{!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)}>
onMouseEnter={() => setActiveIdx(i)}
>
<span className="search-result-emoji">{r.emoji}</span>
<div className="search-result-text">
<strong>{r.title}</strong>
@ -168,9 +206,17 @@ export default function GlobalSearch() {
</button>
))}
{!query && (
<div className="search-hints">
<p>💡 试试:清迈、机票、税居、工具箱</p>
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
<div className="search-ring-hints">
<p className="search-ring-label">从一条路径开始</p>
<div className="search-ring-grid">
{emptyShortcuts.map((p) => (
<button key={p.url} type="button" className="search-ring-chip" onClick={() => navigate(p.url)}>
<span>{p.emoji}</span>
<strong>{p.title}</strong>
</button>
))}
</div>
<p className="search-hints-kbd">⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
</div>
)}
</div>

View File

@ -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<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta>(() => ({
@ -605,6 +608,13 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
</section>
</aside>
</div>
<RingNext
steps={[
...rings.afterCity,
{ href: "/#visa", emoji: "📋", label: t.nav.visa, desc: t.journey.step3Desc },
].slice(0, 3)}
/>
</div>
</div>
);

View File

@ -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<string[]>(["社区活跃"]);
const [moreTags, setMoreTags] = useState(false);
const [results, setResults] = useState<RecommendedDestination[]>([]);
const [loading, setLoading] = useState(false);
const [meetups] = useState(initialMeetups);
@ -157,7 +162,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
<div className="next-stop-filter-card">
<label>{t.nextStop.tags}</label>
<div className="next-stop-chips">
{TAGS.map((tag) => (
{(moreTags ? [...PRIMARY_TAGS, ...EXTRA_TAGS] : PRIMARY_TAGS).map((tag) => (
<button
key={tag}
type="button"
@ -167,6 +172,11 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{tag}
</button>
))}
{!moreTags && (
<button type="button" className="filter-btn" onClick={() => setMoreTags(true)}>
更多…
</button>
)}
</div>
</div>
</aside>
@ -290,6 +300,8 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
)}
</div>
</div>
<RingNext steps={rings.afterDiscover} />
</div>
</div>
);

View File

@ -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 (
<div className="onboard-card" role="dialog" aria-label="新手引导">
<button className="onboard-skip" onClick={finish}>跳过</button>
<button className="onboard-skip" onClick={finish}>
跳过
</button>
<div className="onboard-step">
<span className="onboard-emoji">{current.emoji}</span>
<strong>{current.title}</strong>
@ -60,7 +83,7 @@ export default function OnboardingTour() {
))}
</div>
<button className="btn btn-primary btn-sm" onClick={next}>
{step >= STEPS.length - 1 ? "打开工具箱 →" : "下一步 →"}
{isLast ? "去学院看看 →" : step === 0 ? "开始匹配 →" : "下一步 →"}
</button>
</div>
</div>