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;
|
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 { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Destination } from "@/lib/types";
|
import type { Destination } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
@ -24,6 +26,7 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [picked, setPicked] = useState<string[]>([]);
|
const [picked, setPicked] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -224,6 +227,8 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
</svg>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RingNext steps={rings.afterCity} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import type { Destination } from "@/lib/types";
|
import type { Destination } from "@/lib/types";
|
||||||
import FavoriteButton from "./FavoriteButton";
|
import FavoriteButton from "./FavoriteButton";
|
||||||
|
|
||||||
const INITIAL = 8;
|
const INITIAL = 6;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
|
|||||||
@ -15,7 +15,17 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
page: "页面",
|
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: "/plan" },
|
||||||
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
|
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
|
||||||
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
|
{ 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: "/chat" },
|
||||||
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
|
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
|
||||||
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
|
{ 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: "/videos" },
|
||||||
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
|
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
|
||||||
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
|
{ 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: "合作与媒体", emoji: "📬", url: "/contact" },
|
||||||
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
|
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
|
||||||
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
|
{ 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: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
||||||
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
||||||
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
||||||
@ -48,16 +62,22 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
|
|||||||
const tools = TOOL_LINKS
|
const tools = TOOL_LINKS
|
||||||
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
|
.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"))
|
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
|
||||||
|
.slice(0, 8)
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
type: "tool",
|
type: "tool" as const,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
subtitle: t.desc,
|
subtitle: t.desc,
|
||||||
emoji: t.emoji,
|
emoji: t.emoji,
|
||||||
url: t.href,
|
url: t.href,
|
||||||
}));
|
}));
|
||||||
const pages = PAGE_RESULTS.filter(
|
|
||||||
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
|
const matchPage = (p: SearchResult) =>
|
||||||
).filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
|
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];
|
return [...tools, ...pages];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,16 +103,19 @@ export default function GlobalSearch() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) { setResults([]); return; }
|
if (!q.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const local = searchLocal(q, isHome);
|
const local = searchLocal(q, isHome);
|
||||||
try {
|
try {
|
||||||
const data = await api.search(q);
|
const data = await api.search(q);
|
||||||
const merged = [...local, ...data].slice(0, 20);
|
const merged = [...local, ...data].slice(0, 16);
|
||||||
setResults(merged);
|
setResults(merged);
|
||||||
setActiveIdx(0);
|
setActiveIdx(0);
|
||||||
} catch {
|
} catch {
|
||||||
setResults(local);
|
setResults(local.slice(0, 16));
|
||||||
setActiveIdx(0);
|
setActiveIdx(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -118,16 +141,27 @@ export default function GlobalSearch() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
|
if (e.key === "ArrowDown") {
|
||||||
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
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);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
|
<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">
|
<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>
|
</svg>
|
||||||
<span>搜索...</span>
|
<span>搜索...</span>
|
||||||
<kbd>⌘K</kbd>
|
<kbd>⌘K</kbd>
|
||||||
@ -138,11 +172,12 @@ export default function GlobalSearch() {
|
|||||||
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="search-input-wrap">
|
<div className="search-input-wrap">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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>
|
</svg>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="搜索目的地、工具、签证、博客…"
|
placeholder="搜城市、活动、工具… 或从下方路径进入"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@ -154,23 +189,34 @@ export default function GlobalSearch() {
|
|||||||
{!loading && query && results.length === 0 && (
|
{!loading && query && results.length === 0 && (
|
||||||
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||||||
)}
|
)}
|
||||||
{!loading && results.map((r, i) => (
|
{!loading &&
|
||||||
<button key={`${r.type}-${r.url}-${r.title}`}
|
results.map((r, i) => (
|
||||||
className={`search-result${i === activeIdx ? " active" : ""}`}
|
<button
|
||||||
onClick={() => navigate(r.url)}
|
key={`${r.type}-${r.url}-${r.title}`}
|
||||||
onMouseEnter={() => setActiveIdx(i)}>
|
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||||||
<span className="search-result-emoji">{r.emoji}</span>
|
onClick={() => navigate(r.url)}
|
||||||
<div className="search-result-text">
|
onMouseEnter={() => setActiveIdx(i)}
|
||||||
<strong>{r.title}</strong>
|
>
|
||||||
<span>{r.subtitle}</span>
|
<span className="search-result-emoji">{r.emoji}</span>
|
||||||
</div>
|
<div className="search-result-text">
|
||||||
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
|
<strong>{r.title}</strong>
|
||||||
</button>
|
<span>{r.subtitle}</span>
|
||||||
))}
|
</div>
|
||||||
|
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{!query && (
|
{!query && (
|
||||||
<div className="search-hints">
|
<div className="search-ring-hints">
|
||||||
<p>💡 试试:清迈、机票、税居、工具箱</p>
|
<p className="search-ring-label">从一条路径开始</p>
|
||||||
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync";
|
|||||||
import { buildPlanIcs, downloadPlanIcs } from "@/lib/planIcs";
|
import { buildPlanIcs, downloadPlanIcs } from "@/lib/planIcs";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Destination, TripItem, Visa } from "@/lib/types";
|
import type { Destination, TripItem, Visa } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
destinations: Destination[];
|
destinations: Destination[];
|
||||||
@ -37,6 +39,7 @@ function scoreDest(d: Destination) {
|
|||||||
export default function MovePlanClient({ destinations, visas = [] }: Props) {
|
export default function MovePlanClient({ destinations, visas = [] }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const { token, user, planSyncStatus } = useAuth();
|
const { token, user, planSyncStatus } = useAuth();
|
||||||
const [trip, setTrip] = useState<TripItem[]>([]);
|
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||||
const [meta, setMeta] = useState<PlanMeta>(() => ({
|
const [meta, setMeta] = useState<PlanMeta>(() => ({
|
||||||
@ -605,6 +608,13 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RingNext
|
||||||
|
steps={[
|
||||||
|
...rings.afterCity,
|
||||||
|
{ href: "/#visa", emoji: "📋", label: t.nav.visa, desc: t.journey.step3Desc },
|
||||||
|
].slice(0, 3)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
|||||||
import { useToast } from "@/lib/toast";
|
import { useToast } from "@/lib/toast";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Destination, Meetup, NomadRoute, RecommendedDestination } from "@/lib/types";
|
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 = [
|
const CLIMATE_OPTIONS = [
|
||||||
{ value: "mild", label: "🌤️ 温和" },
|
{ value: "mild", label: "🌤️ 温和" },
|
||||||
{ value: "warm", 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) {
|
export default function NextStopClient({ destinations, initialMeetups, initialRoutes }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [budget, setBudget] = useState(8000);
|
const [budget, setBudget] = useState(8000);
|
||||||
const [internet, setInternet] = useState(50);
|
const [internet, setInternet] = useState(50);
|
||||||
const [climate, setClimate] = useState("mild");
|
const [climate, setClimate] = useState("mild");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>(["社区活跃"]);
|
const [selectedTags, setSelectedTags] = useState<string[]>(["社区活跃"]);
|
||||||
|
const [moreTags, setMoreTags] = useState(false);
|
||||||
const [results, setResults] = useState<RecommendedDestination[]>([]);
|
const [results, setResults] = useState<RecommendedDestination[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [meetups] = useState(initialMeetups);
|
const [meetups] = useState(initialMeetups);
|
||||||
@ -157,7 +162,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
|
|||||||
<div className="next-stop-filter-card">
|
<div className="next-stop-filter-card">
|
||||||
<label>{t.nextStop.tags}</label>
|
<label>{t.nextStop.tags}</label>
|
||||||
<div className="next-stop-chips">
|
<div className="next-stop-chips">
|
||||||
{TAGS.map((tag) => (
|
{(moreTags ? [...PRIMARY_TAGS, ...EXTRA_TAGS] : PRIMARY_TAGS).map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
type="button"
|
type="button"
|
||||||
@ -167,6 +172,11 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
|
|||||||
{tag}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{!moreTags && (
|
||||||
|
<button type="button" className="filter-btn" onClick={() => setMoreTags(true)}>
|
||||||
|
更多…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -290,6 +300,8 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RingNext steps={rings.afterDiscover} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,11 +2,31 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Align onboarding with the three product rings — never dump the full catalog. */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏感兴趣的目的地", action: "scroll" as const },
|
{
|
||||||
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "回答几个问题,获得推荐城市", action: "matcher" as const },
|
id: "path",
|
||||||
{ id: "visa", emoji: "🛂", title: "签证指南", desc: "查看热门游民签证与停留政策", action: "scroll" as const },
|
emoji: "🌍",
|
||||||
{ id: "tools", emoji: "🧰", title: "工具箱", desc: "费用、时差、打包等实用小工具", action: "route" as const, href: "/tools" },
|
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() {
|
export default function OnboardingTour() {
|
||||||
@ -46,10 +66,13 @@ export default function OnboardingTour() {
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const current = STEPS[step];
|
const current = STEPS[step];
|
||||||
|
const isLast = step >= STEPS.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="onboard-card" role="dialog" aria-label="新手引导">
|
<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">
|
<div className="onboard-step">
|
||||||
<span className="onboard-emoji">{current.emoji}</span>
|
<span className="onboard-emoji">{current.emoji}</span>
|
||||||
<strong>{current.title}</strong>
|
<strong>{current.title}</strong>
|
||||||
@ -60,7 +83,7 @@ export default function OnboardingTour() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-sm" onClick={next}>
|
<button className="btn btn-primary btn-sm" onClick={next}>
|
||||||
{step >= STEPS.length - 1 ? "打开工具箱 →" : "下一步 →"}
|
{isLast ? "去学院看看 →" : step === 0 ? "开始匹配 →" : "下一步 →"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user