diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index a914709..c8bf6b7 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -16,6 +16,7 @@ const LOGS = [ "Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固", "Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环", "Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示", + "边缘体验:404/错误页环导航、sitemap 补全、匹配空态、快捷键与贴士持久化", ], }, { diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..104327b --- /dev/null +++ b/frontend/src/app/error.tsx @@ -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 ( + +
+

+ 出错了 +

+

页面暂时无法加载,可以重试或换一条航线

+

{error.digest ? `参考码 ${error.digest}` : "请稍后再试"}

+
+ + + 首页 + + + 反馈问题 + +
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a63d374..ca38737 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index 4c63d61..7f916e3 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -3,7 +3,7 @@ import SiteShell from "@/components/SiteShell"; export default function NotFound() { return ( - +

404

哎呀,这片海域没有标记的岛屿

-

你可能走错了航线,让我们帮你回到正轨

+

你可能走错了航线,从三条产品环任选一条继续

- 返回首页 🏠 - 探索目的地 🌍 + + 返回首页 + + + 智能下一站 + + + 游民活动 + + + 游民学院 + + + 帮助中心 +
diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 000f6f6..a7a1858 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -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 }, diff --git a/frontend/src/components/DestinationMatcher.tsx b/frontend/src/components/DestinationMatcher.tsx index bbb4996..eac4018 100644 --- a/frontend/src/components/DestinationMatcher.tsx +++ b/frontend/src/components/DestinationMatcher.tsx @@ -215,8 +215,22 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop

为你推荐的目的地

偏好已保存。可一键写入旅居计划,或先对比再决定。

+ {results.length === 0 ? ( +
+

暂无匹配结果,目的地数据可能未加载

+
+ + + 去智能下一站 + +
+
+ ) : ( + <>
- {results.map(({ dest, score }, i) => ( + {results.slice(0, 8).map(({ dest, score }, i) => (
@@ -246,7 +260,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop ))}
- + {top3.length >= 2 && ( d.slug))} @@ -256,10 +270,12 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop ⚖️ 对比 Top {top3.length} )} -
+ + )}
)}
diff --git a/frontend/src/components/FAQSection.tsx b/frontend/src/components/FAQSection.tsx index df57615..fe4a415 100644 --- a/frontend/src/components/FAQSection.tsx +++ b/frontend/src/components/FAQSection.tsx @@ -62,6 +62,23 @@ export default function FAQSection({ faqs, limit }: Props) { ))} + {filtered.length === 0 && ( +
+

{locale === "en" ? "No FAQ matched" : "没有匹配的问题"}

+
+ + + {locale === "en" ? "Help center" : "帮助中心"} + + + {locale === "en" ? "Ask AI" : "问 AI"} + +
+
+ )} + {limit && !showAll && sorted.length > limit && (
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx index 78eb78c..29ff7d4 100644 --- a/frontend/src/components/KeyboardShortcuts.tsx +++ b/frontend/src/components/KeyboardShortcuts.tsx @@ -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 ( -
e.target === e.currentTarget && setOpen(false)}> -
- +
e.target === e.currentTarget && setOpen(false)} + > +
+
⌨️ SHORTCUTS

键盘快捷键

-

提升浏览效率,按 ? 随时唤起

+

+ 提升浏览效率,按 ? 随时唤起 +

{SHORTCUTS.map((s) => (
{s.icon} {s.label}
- {s.keys.map((k) => {k})} + {s.keys.map((k) => ( + {k} + ))}
))} diff --git a/frontend/src/components/NomadTips.tsx b/frontend/src/components/NomadTips.tsx index 1579cf2..b97f65a 100644 --- a/frontend/src/components/NomadTips.tsx +++ b/frontend/src/components/NomadTips.tsx @@ -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 (
- + {tip.emoji}
游民小贴士

{tip.text}

+ + 了解更多 → +
{TIPS.map((_, i) => ( diff --git a/frontend/src/components/ShareButton.tsx b/frontend/src/components/ShareButton.tsx index 2a16491..d17de27 100644 --- a/frontend/src/components/ShareButton.tsx +++ b/frontend/src/components/ShareButton.tsx @@ -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 ( - ); @@ -49,16 +66,22 @@ export default function ShareButton({ title, url, className = "", compact = fals return (
- {open && ( <>
setOpen(false)} /> -
- - - +
+ + +
)} diff --git a/frontend/src/lib/staticPages.ts b/frontend/src/lib/staticPages.ts index c078995..5bb5dcb 100644 --- a/frontend/src/lib/staticPages.ts +++ b/frontend/src/lib/staticPages.ts @@ -80,6 +80,11 @@ export const STATIC_PAGES: Record = { }, { heading: "违规处理", body: "内容可能被隐藏,严重者封禁账号。" }, ], + cta: [ + { label: "社区讨论", href: "/community" }, + { label: "安全指南", href: "/safety" }, + { label: "提交反馈", href: "/feedback" }, + ], }, terms: { tag: "📋 TERMS", @@ -90,6 +95,10 @@ export const STATIC_PAGES: Record = { { heading: "用户责任", body: "你对自己发布的内容负责,并遵守当地法律法规。" }, { heading: "免责声明", body: "目的地数据、签证信息仅供参考,请以官方渠道为准。" }, ], + cta: [ + { label: "隐私政策", href: "/privacy" }, + { label: "社区准则", href: "/guidelines" }, + ], }, cookies: { tag: "🍪 COOKIES", @@ -109,6 +118,11 @@ export const STATIC_PAGES: Record = { { heading: "媒体联系", body: "press@nomadro.com" }, { heading: "数据亮点", body: "覆盖 195+ 国家目的地数据、52+ 旅居工具、活跃游民社区。" }, ], + cta: [ + { label: "关于我们", href: "/about" }, + { label: "更新日志", href: "/changelog" }, + { label: "联系合作", href: "/contact" }, + ], }, careers: { tag: "💼 CAREERS",