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:
parent
775f90dc6d
commit
969a0b037a
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user