From dac99e8ef4a840062bd311a693403c3556b343d8 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 21:11:28 -0500 Subject: [PATCH] Polish map empty states, login hints, PWA install, live mobile. Co-authored-by: Cursor --- frontend/src/app/changelog/page.tsx | 10 ++++ frontend/src/app/digital/day/[id]/page.tsx | 41 +++++++++++--- frontend/src/app/globals.css | 51 ++++++++++++++++- frontend/src/app/login/page.tsx | 23 +++++++- frontend/src/components/CookieConsent.tsx | 8 ++- frontend/src/components/Destinations.tsx | 25 ++++++++- frontend/src/components/DigitalHomeClient.tsx | 5 ++ frontend/src/components/HomeClient.tsx | 2 + frontend/src/components/MemberMapClient.tsx | 45 ++++++++++++--- frontend/src/components/PwaInstallHint.tsx | 55 +++++++++++++++++++ frontend/src/lib/staticPages.ts | 14 ++++- 11 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/PwaInstallHint.tsx diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 5d152c4..a914709 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,16 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-08-30", + tag: "三环可用深化", + items: [ + "产品信息架构:Explore / Connect / Grow 渐进披露,导航与空态对齐三条环", + "Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固", + "Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环", + "Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示", + ], + }, { date: "2026-08-29", tag: "电子书融合", diff --git a/frontend/src/app/digital/day/[id]/page.tsx b/frontend/src/app/digital/day/[id]/page.tsx index 2bc86b8..2cdb36d 100644 --- a/frontend/src/app/digital/day/[id]/page.tsx +++ b/frontend/src/app/digital/day/[id]/page.tsx @@ -14,15 +14,42 @@ export default async function DigitalDayPage({ params }: { params: Promise<{ id: const { id } = await params; const day = await api.getDigitalDay(id).catch(() => null); if (!day) notFound(); + const n = Number(id); + const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null; + const next = Number.isFinite(n) && n < 3 ? String(n + 1) : null; + return ( -
- -
- 📅 DAY {id} -

{day.title}

-

{day.body}

-
+
+
+ +
+ 📅 DAY {id} +

{day.title}

+
+ {day.body.split("\n").filter(Boolean).map((p, i) => ( +

{p}

+ ))} +
+
+ {prev && ( + + ← 上一篇 + + )} + {next && ( + + 下一篇 → + + )} + + 课程目录 + +
+
+
); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 15f4e10..a63d374 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5688,6 +5688,12 @@ img { max-width: 100%; display: block; } padding: 4px; } +.auth-pwd-hint { + margin-top: 6px; + font-size: 0.78rem; + color: var(--text-muted); +} + .auth-submit { width: 100%; } @@ -6850,6 +6856,32 @@ img { max-width: 100%; display: block; } .phrase-zh { font-size: 0.85rem; color: var(--text-secondary); } /* ===== Feedback Widget ===== */ +.pwa-install-hint { + position: fixed; + left: 16px; + right: 16px; + bottom: 24px; + z-index: 8700; + max-width: 420px; + margin: 0 auto; + padding: 14px 16px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + backdrop-filter: blur(16px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.pwa-install-hint p { + margin: 0 0 10px; + font-size: 0.9rem; +} + +.pwa-install-actions { + display: flex; + gap: 8px; +} + .feedback-fab { position: fixed; bottom: 100px; @@ -10919,8 +10951,25 @@ img { max-width: 100%; display: block; } @media (max-width: 768px) { .join-grid { grid-template-columns: 1fr; } + .live-toolbar { + padding: 10px 12px; + gap: 8px; + } + .live-toolbar strong { + flex: 1 1 100%; + font-size: 0.95rem; + } + .live-layout-btns { + margin-left: 0; + width: 100%; + justify-content: flex-start; + } + .live-stage { + min-height: calc(100vh - 110px); + } .live-stage.layout-split { grid-template-columns: 1fr; } - .live-chat { border-left: none; border-top: var(--border-glass); min-height: 320px; } + .live-iframe { min-height: 280px; } + .live-chat { border-left: none; border-top: var(--border-glass); min-height: 280px; } } /* -- Feature rings / progressive IA -- */ diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 8879e58..a2bc974 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -34,10 +34,20 @@ function LoginForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + if (mode === "register") { + if (!name.trim() || name.trim().length < 2) { + setError("昵称至少 2 个字符"); + return; + } + if (password.length < 6) { + setError("密码至少 6 位"); + return; + } + } setLoading(true); const ok = mode === "login" ? await login(email, password) - : await register(email, password, name); + : await register(email, password, name.trim()); setLoading(false); if (ok) { goAfterAuth( @@ -116,6 +126,17 @@ function LoginForm() { {showPwd ? "🙈" : "👁️"}
+ {mode === "register" && ( +

+ {password.length === 0 + ? "至少 6 位,建议包含字母与数字" + : password.length < 6 + ? `还差 ${6 - password.length} 位` + : password.length < 10 + ? "可用 · 再长一点更安全" + : "强度不错"} +

+ )} {error &&

{error}

} + ); diff --git a/frontend/src/components/Destinations.tsx b/frontend/src/components/Destinations.tsx index a658a5b..76ccb20 100644 --- a/frontend/src/components/Destinations.tsx +++ b/frontend/src/components/Destinations.tsx @@ -113,7 +113,30 @@ export default function Destinations({ destinations, onOpenMatcher }: Props) { {filtered.length === 0 && ( -

{t.home.destEmpty}

+
+

{t.home.destEmpty}

+
+ + {onOpenMatcher && ( + + )} + + 智能下一站 + +
+
)} {canExpand && ( diff --git a/frontend/src/components/DigitalHomeClient.tsx b/frontend/src/components/DigitalHomeClient.tsx index 60e68b9..0b41539 100644 --- a/frontend/src/components/DigitalHomeClient.tsx +++ b/frontend/src/components/DigitalHomeClient.tsx @@ -43,6 +43,11 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })

{t.digital.vip}

{t.digital.vipDesc}

+ + 📅 +

每日指南

+

从税务居民到落地验网,三天起步清单

+ import("./NomadTips")); const FeedbackWidget = lazy(() => import("./FeedbackWidget")); const OnboardingTour = lazy(() => import("./OnboardingTour")); const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts")); +const PwaInstallHint = lazy(() => import("./PwaInstallHint")); interface Props { stats: Stats; @@ -139,6 +140,7 @@ export default function HomeClient(props: Props) { <> + diff --git a/frontend/src/components/MemberMapClient.tsx b/frontend/src/components/MemberMapClient.tsx index 2b0138c..2710ab8 100644 --- a/frontend/src/components/MemberMapClient.tsx +++ b/frontend/src/components/MemberMapClient.tsx @@ -5,29 +5,53 @@ import Link from "next/link"; import { api } from "@/lib/api"; import { useI18n } from "@/lib/i18n"; import type { MemberMapItem } from "@/lib/types"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; export default function MemberMapClient() { const { t } = useI18n(); + const rings = useRingSteps(); const [members, setMembers] = useState([]); const [weather, setWeather] = useState<{ name: string; temperature: number | null }[]>([]); + const [loading, setLoading] = useState(true); useEffect(() => { - api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {}); - api.getWeatherCities().then(setWeather).catch(() => {}); + Promise.all([ + api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => setMembers([])), + api.getWeatherCities().then(setWeather).catch(() => setWeather([])), + ]).finally(() => setLoading(false)); }, []); return (
- +
{t.mapPage.tag}

{t.mapPage.title}

{t.mapPage.subtitle}

+ {loading &&

加载地图…

} + {!loading && members.length === 0 && ( +
+

暂无公开会员分布

+
+ + 去匹配 + + + 去活动 + +
+
+ )}
{members.map((m) => ( -
+
{m.emoji}
{m.name} @@ -36,9 +60,11 @@ export default function MemberMapClient() {
))}
- {weather.length > 0 && ( -
-

{t.mapPage.weather}

+
+

{t.mapPage.weather}

+ {!loading && weather.length === 0 ? ( +

天气暂不可用

+ ) : (
{weather.map((w) => (
@@ -47,9 +73,10 @@ export default function MemberMapClient() {
))}
-
- )} + )} +

{t.mapPage.hint}

+
); diff --git a/frontend/src/components/PwaInstallHint.tsx b/frontend/src/components/PwaInstallHint.tsx new file mode 100644 index 0000000..8029880 --- /dev/null +++ b/frontend/src/components/PwaInstallHint.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "@/lib/i18n"; + +/** Soft install hint — shows once when browser fires beforeinstallprompt. */ +export default function PwaInstallHint() { + const { t } = useI18n(); + const [deferred, setDeferred] = useState<{ prompt: () => Promise } | null>(null); + const [hidden, setHidden] = useState(true); + + useEffect(() => { + if (window.matchMedia("(display-mode: standalone)").matches) return; + if (localStorage.getItem("nomadro-pwa-hint") === "1") return; + + const onBip = (e: Event) => { + e.preventDefault(); + const ev = e as Event & { prompt: () => Promise }; + setDeferred({ prompt: () => ev.prompt() }); + setHidden(false); + }; + window.addEventListener("beforeinstallprompt", onBip); + return () => window.removeEventListener("beforeinstallprompt", onBip); + }, []); + + if (hidden || !deferred) return null; + + const dismiss = () => { + localStorage.setItem("nomadro-pwa-hint", "1"); + setHidden(true); + }; + + const install = async () => { + try { + await deferred.prompt(); + } catch { + /* ignore */ + } + dismiss(); + }; + + return ( +
+

{t.pwa.installHint}

+
+ + +
+
+ ); +} diff --git a/frontend/src/lib/staticPages.ts b/frontend/src/lib/staticPages.ts index 1d76d37..c078995 100644 --- a/frontend/src/lib/staticPages.ts +++ b/frontend/src/lib/staticPages.ts @@ -121,7 +121,11 @@ export const STATIC_PAGES: Record = { }, { heading: "投递", body: "hello@nomadro.com,标题注明「应聘 - 岗位名」。" }, ], - cta: [{ label: "赏金任务", href: "/gigs" }], + cta: [ + { label: "赏金任务", href: "/gigs" }, + { label: "内容投稿", href: "/submit" }, + { label: "联系邮箱", href: "mailto:hello@nomadro.com" }, + ], }, support: { tag: "🤝 SUPPORT", @@ -130,7 +134,13 @@ export const STATIC_PAGES: Record = { { heading: "成为 VIP", body: "解锁匹配、直播、学院课程,支持平台持续运营。" }, { heading: "分享", body: "推荐给朋友,或在社交媒体分享你的旅居故事。" }, { heading: "贡献内容", body: "投稿视频、电子书或博客,经审核后展示。" }, + { heading: "反馈问题", body: "发现 Bug 或有产品想法,欢迎直接提交反馈。" }, + ], + cta: [ + { label: "开通会员", href: "/join" }, + { label: "内容投稿", href: "/submit" }, + { label: "提交反馈", href: "/feedback" }, + { label: "电子书", href: "/book" }, ], - cta: [{ label: "开通会员", href: "/join" }, { label: "内容投稿", href: "/submit" }], }, };