Polish edge UX: 404/error rings, sitemap, matcher empty, shortcuts and tips.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 21:24:54 -05:00
parent dac99e8ef4
commit 2ee0f3bf77
11 changed files with 214 additions and 40 deletions

View File

@ -16,6 +16,7 @@ const LOGS = [
"Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固",
"Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环",
"Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示",
"边缘体验:404/错误页环导航、sitemap 补全、匹配空态、快捷键与贴士持久化",
],
},
{

View File

@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<SiteShell showFooter>
<div className="not-found-page">
<h1 className="not-found-title" style={{ fontSize: "2.5rem" }}>
出错了
</h1>
<p>页面暂时无法加载,可以重试或换一条航线</p>
<p className="not-found-sub">{error.digest ? `参考码 ${error.digest}` : "请稍后再试"}</p>
<div className="not-found-actions">
<button type="button" className="btn btn-primary" onClick={reset}>
重试
</button>
<Link href="/" className="btn">
首页
</Link>
<Link href="/feedback" className="btn btn-ghost">
反馈问题
</Link>
</div>
</div>
</SiteShell>
);
}

View File

@ -6082,6 +6082,18 @@ img { max-width: 100%; display: block; }
animation: toastIn 0.3s ease;
}
.nomad-tips-link {
display: inline-block;
margin-top: 8px;
font-size: 0.8rem;
color: var(--accent-3);
text-decoration: none;
}
.nomad-tips-link:hover {
text-decoration: underline;
}
.nomad-tips-close {
position: absolute;
top: 8px;

View File

@ -3,7 +3,7 @@ import SiteShell from "@/components/SiteShell";
export default function NotFound() {
return (
<SiteShell>
<SiteShell showFooter>
<div className="not-found-page">
<div className="not-found-compass" aria-hidden="true">
<span className="compass-ring">🧭</span>
@ -11,10 +11,23 @@ export default function NotFound() {
</div>
<h1 className="not-found-title">404</h1>
<p>哎呀,这片海域没有标记的岛屿</p>
<p className="not-found-sub">你可能走错了航线,让我们帮你回到正轨</p>
<p className="not-found-sub">你可能走错了航线,从三条产品环任选一条继续</p>
<div className="not-found-actions">
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
<Link href="/#destinations" className="btn btn-ghost">探索目的地 🌍</Link>
<Link href="/" className="btn btn-primary">
返回首页
</Link>
<Link href="/next-stop" className="btn">
智能下一站
</Link>
<Link href="/meetups" className="btn">
游民活动
</Link>
<Link href="/digital" className="btn">
游民学院
</Link>
<Link href="/help" className="btn btn-ghost">
帮助中心
</Link>
</div>
</div>
</SiteShell>

View File

@ -35,11 +35,20 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/join`, lastModified: now, changeFrequency: "monthly", priority: 0.75 },
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/digital/day/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/2`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/3`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/careers`, lastModified: now, changeFrequency: "monthly", priority: 0.45 },
{ url: `${SITE}/safety`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/guidelines`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/cookies`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/press`, lastModified: now, changeFrequency: "yearly", priority: 0.35 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },

View File

@ -215,8 +215,22 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
<h2>为你推荐的目的地</h2>
<p>偏好已保存。可一键写入旅居计划,或先对比再决定。</p>
</div>
{results.length === 0 ? (
<div className="notif-empty">
<p className="dest-empty">暂无匹配结果,目的地数据可能未加载</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>
重新测试
</button>
<Link href="/next-stop" className="btn btn-primary" onClick={onClose}>
去智能下一站
</Link>
</div>
</div>
) : (
<>
<div className="matcher-result-list">
{results.map(({ dest, score }, i) => (
{results.slice(0, 8).map(({ dest, score }, i) => (
<div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
<Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
<div className="matcher-result-rank">
@ -246,7 +260,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
))}
</div>
<div className="matcher-actions">
<button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
<button type="button" className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
{top3.length >= 2 && (
<Link
href={compareUrl(top3.map((d) => d.slug))}
@ -256,10 +270,12 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
⚖️ 对比 Top {top3.length}
</Link>
)}
<button className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
<button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
把 Top {Math.min(3, top3.length)} 写入计划 →
</button>
</div>
</>
)}
</div>
)}
</div>

View File

@ -62,6 +62,23 @@ export default function FAQSection({ faqs, limit }: Props) {
))}
</div>
{filtered.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{locale === "en" ? "No FAQ matched" : "没有匹配的问题"}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => setSearch("")}>
{locale === "en" ? "Clear search" : "清除搜索"}
</button>
<a href="/help" className="btn btn-sm btn-ghost">
{locale === "en" ? "Help center" : "帮助中心"}
</a>
<a href="/ai" className="btn btn-sm btn-ghost">
{locale === "en" ? "Ask AI" : "问 AI"}
</a>
</div>
</div>
)}
{limit && !showAll && sorted.length > limit && (
<div className="dest-expand-wrap reveal">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
@ -73,6 +90,10 @@ export default function FAQSection({ faqs, limit }: Props) {
<p className="faq-plan-hint reveal">
{locale === "en" ? "Still exploring? " : "还在选城?"}
<a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a>
{" · "}
<a href="/help">{locale === "en" ? "Help center" : "帮助中心"}</a>
{" · "}
<a href="/ai">{locale === "en" ? "Ask AI" : "问 AI"}</a>
</p>
</div>
</section>

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Props {
onOpenMatcher?: () => void;
@ -9,21 +10,21 @@ interface Props {
const SHORTCUTS = [
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["N"], label: "下一站", icon: "🧭" },
{ keys: ["P"], label: "旅居计划", icon: "🗓️" },
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
{ keys: ["Enter"], label: "确认选择", icon: "↵" },
];
export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
const typing = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (e.target as HTMLElement)?.isContentEditable;
if (e.key === "?" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
@ -33,37 +34,50 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault();
onOpenMatcher?.();
}
if (e.key === "g" && !typing && !e.metaKey && !e.ctrlKey) {
if (e.key === "n" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
document.getElementById("spin")?.scrollIntoView({ behavior: "smooth" });
router.push("/next-stop");
}
if (e.key === "p" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
router.push("/plan");
}
if (e.key === "t" && !typing && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
window.location.href = "/tools";
router.push("/tools");
}
if (e.key === "Escape" && open) setOpen(false);
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenMatcher]);
}, [onOpenMatcher, router]);
if (!open) return null;
return (
<div className="modal-overlay open shortcuts-overlay" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
<div className="modal shortcuts-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button>
<div
className="modal-overlay open shortcuts-overlay"
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
>
<div className="modal shortcuts-modal" role="dialog" aria-label="键盘快捷键">
<button type="button" className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">
✕
</button>
<div className="shortcuts-body">
<span className="section-tag">⌨️ SHORTCUTS</span>
<h2>键盘快捷键</h2>
<p>提升浏览效率,按 <kbd>?</kbd> 随时唤起</p>
<p>
提升浏览效率,按 <kbd>?</kbd> 随时唤起
</p>
<div className="shortcuts-grid">
{SHORTCUTS.map((s) => (
<div key={s.label} className="shortcut-item">
<span className="shortcut-icon">{s.icon}</span>
<span className="shortcut-label">{s.label}</span>
<div className="shortcut-keys">
{s.keys.map((k) => <kbd key={k}>{k}</kbd>)}
{s.keys.map((k) => (
<kbd key={k}>{k}</kbd>
))}
</div>
</div>
))}

View File

@ -1,14 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
const TIPS = [
{ emoji: "📶", text: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约" },
{ emoji: "💳", text: "准备一张无外汇手续费的信用卡,省下不少换汇成本" },
{ emoji: "🏥", text: "出发前购买 SafetyWing 等国际医疗保险,月费约 $40+" },
{ emoji: "🕐", text: "选时区重叠 ≥4 小时的城市,远程协作更轻松" },
{ emoji: "🛂", text: "护照有效期建议预留 6 个月以上,避免登机被拒" },
{ emoji: "☕", text: "先订 1 周 Airbnb 探路,再决定长期租房" },
{ emoji: "📶", text: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约", href: "/digital/day/3" },
{ emoji: "💳", text: "准备一张无外汇手续费的信用卡,省下不少换汇成本", href: "/digital/day/2" },
{ emoji: "🏥", text: "出发前购买国际医疗保险,月费约 $40+", href: "/tools" },
{ emoji: "🕐", text: "选时区重叠 ≥4 小时的城市,远程协作更轻松", href: "/next-stop" },
{ emoji: "🛂", text: "护照有效期建议预留 6 个月以上,避免登机被拒", href: "/digital/day/1" },
{ emoji: "☕", text: "先订 1 周短租探路,再决定长期租房", href: "/plan" },
];
export default function NomadTips() {
@ -16,6 +17,7 @@ export default function NomadTips() {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (localStorage.getItem("nomadro-tips-dismissed") === "1") return;
const show = setTimeout(() => setVisible(true), 8000);
return () => clearTimeout(show);
}, []);
@ -29,14 +31,23 @@ export default function NomadTips() {
if (!visible) return null;
const tip = TIPS[idx];
const dismiss = () => {
localStorage.setItem("nomadro-tips-dismissed", "1");
setVisible(false);
};
return (
<div className="nomad-tips" role="status">
<button className="nomad-tips-close" onClick={() => setVisible(false)} aria-label="关闭">✕</button>
<button type="button" className="nomad-tips-close" onClick={dismiss} aria-label="关闭">
✕
</button>
<span className="nomad-tips-emoji">{tip.emoji}</span>
<div className="nomad-tips-body">
<strong>游民小贴士</strong>
<p key={idx}>{tip.text}</p>
<Link href={tip.href} className="nomad-tips-link" onClick={dismiss}>
了解更多 →
</Link>
</div>
<div className="nomad-tips-dots">
{TIPS.map((_, i) => (

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast";
interface Props {
@ -16,16 +16,33 @@ export default function ShareButton({ title, url, className = "", compact = fals
const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : "");
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const copyLink = async () => {
await navigator.clipboard.writeText(getUrl());
toast("链接已复制到剪贴板 📋");
try {
await navigator.clipboard.writeText(getUrl());
toast("链接已复制到剪贴板 📋");
} catch {
toast("复制失败,请手动复制地址栏", "error");
}
setOpen(false);
};
const nativeShare = async () => {
const shareUrl = getUrl();
if (navigator.share) {
await navigator.share({ title, url: shareUrl });
try {
await navigator.share({ title, url: shareUrl });
} catch {
/* user cancelled */
}
setOpen(false);
} else {
await copyLink();
@ -35,13 +52,13 @@ export default function ShareButton({ title, url, className = "", compact = fals
const shareTwitter = () => {
const shareUrl = encodeURIComponent(getUrl());
const text = encodeURIComponent(title);
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${shareUrl}`, "_blank");
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${shareUrl}`, "_blank", "noopener,noreferrer");
setOpen(false);
};
if (compact) {
return (
<button className={`share-btn-compact ${className}`} onClick={nativeShare} aria-label="分享">
<button type="button" className={`share-btn-compact ${className}`} onClick={() => void nativeShare()} aria-label="分享">
📤
</button>
);
@ -49,16 +66,22 @@ export default function ShareButton({ title, url, className = "", compact = fals
return (
<div className={`share-dropdown ${className}`}>
<button className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)}>
<button type="button" className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)} aria-expanded={open}>
📤 分享
</button>
{open && (
<>
<div className="share-backdrop" onClick={() => setOpen(false)} />
<div className="share-menu">
<button onClick={nativeShare}>📱 系统分享</button>
<button onClick={copyLink}>📋 复制链接</button>
<button onClick={shareTwitter}>🐦 分享到 X</button>
<div className="share-menu" role="menu">
<button type="button" onClick={() => void nativeShare()}>
📱 系统分享
</button>
<button type="button" onClick={() => void copyLink()}>
📋 复制链接
</button>
<button type="button" onClick={shareTwitter}>
🐦 分享到 X
</button>
</div>
</>
)}

View File

@ -80,6 +80,11 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
},
{ heading: "违规处理", body: "内容可能被隐藏,严重者封禁账号。" },
],
cta: [
{ label: "社区讨论", href: "/community" },
{ label: "安全指南", href: "/safety" },
{ label: "提交反馈", href: "/feedback" },
],
},
terms: {
tag: "📋 TERMS",
@ -90,6 +95,10 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
{ heading: "用户责任", body: "你对自己发布的内容负责,并遵守当地法律法规。" },
{ heading: "免责声明", body: "目的地数据、签证信息仅供参考,请以官方渠道为准。" },
],
cta: [
{ label: "隐私政策", href: "/privacy" },
{ label: "社区准则", href: "/guidelines" },
],
},
cookies: {
tag: "🍪 COOKIES",
@ -109,6 +118,11 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
{ heading: "媒体联系", body: "press@nomadro.com" },
{ heading: "数据亮点", body: "覆盖 195+ 国家目的地数据、52+ 旅居工具、活跃游民社区。" },
],
cta: [
{ label: "关于我们", href: "/about" },
{ label: "更新日志", href: "/changelog" },
{ label: "联系合作", href: "/contact" },
],
},
careers: {
tag: "💼 CAREERS",