Polish map empty states, login hints, PWA install, live mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 21:11:28 -05:00
parent 9f2eee7e83
commit dac99e8ef4
11 changed files with 256 additions and 23 deletions

View File

@ -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: "电子书融合",

View File

@ -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 (
<SiteShell showFooter>
<div className="legal-page">
<nav className="detail-nav"><Link href="/digital">← 游民学院</Link></nav>
<article className="legal-content reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{day.title}</h1>
<p>{day.body}</p>
</article>
<div className="digital-page">
<div className="container" style={{ maxWidth: 720 }}>
<nav className="detail-nav">
<Link href="/digital">← 游民学院</Link>
</nav>
<article className="digital-lesson reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{day.title}</h1>
<div className="digital-lesson-body">
{day.body.split("\n").filter(Boolean).map((p, i) => (
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && (
<Link href={`/digital/day/${prev}`} className="btn">
← 上一篇
</Link>
)}
{next && (
<Link href={`/digital/day/${next}`} className="btn btn-primary">
下一篇 →
</Link>
)}
<Link href="/digital/course" className="btn btn-ghost">
课程目录
</Link>
</footer>
</article>
</div>
</div>
</SiteShell>
);

View File

@ -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 -- */

View File

@ -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 ? "🙈" : "👁️"}
</button>
</div>
{mode === "register" && (
<p className="auth-pwd-hint">
{password.length === 0
? "至少 6 位,建议包含字母与数字"
: password.length < 6
? `还差 ${6 - password.length} 位`
: password.length < 10
? "可用 · 再长一点更安全"
: "强度不错"}
</p>
)}
</div>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>

View File

@ -25,10 +25,14 @@ export default function CookieConsent() {
<div className="cookie-consent-inner">
<span className="cookie-emoji">🍪</span>
<p>
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
我们使用本地存储保存主题、行程与登录会话,不做广告追踪。
<Link href="/privacy">隐私政策</Link>
{" · "}
<Link href="/cookies">Cookie 说明</Link>
</p>
<button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
<button type="button" className="btn btn-primary btn-sm" onClick={accept}>
知道了 ✓
</button>
</div>
</div>
);

View File

@ -113,7 +113,30 @@ export default function Destinations({ destinations, onOpenMatcher }: Props) {
</div>
{filtered.length === 0 && (
<p className="dest-empty">{t.home.destEmpty}</p>
<div className="notif-empty reveal">
<p className="dest-empty">{t.home.destEmpty}</p>
<div className="dating-empty-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setFilter("all");
setSearch("");
setExpanded(false);
}}
>
清除筛选
</button>
{onOpenMatcher && (
<button type="button" className="btn btn-primary btn-sm" onClick={onOpenMatcher}>
🎯 {t.home.destMatcher}
</button>
)}
<Link href="/next-stop" className="btn btn-sm">
智能下一站
</Link>
</div>
</div>
)}
{canExpand && (

View File

@ -43,6 +43,11 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
<h3>{t.digital.vip}</h3>
<p>{t.digital.vipDesc}</p>
</Link>
<Link href="/digital/day/1" className="digital-card">
<span className="digital-card-emoji">📅</span>
<h3>每日指南</h3>
<p>从税务居民到落地验网,三天起步清单</p>
</Link>
</div>
<RingNext

View File

@ -33,6 +33,7 @@ const NomadTips = lazy(() => 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) {
<>
<KeyboardShortcuts onOpenMatcher={openMatcher} />
<CookieConsent />
<PwaInstallHint />
<NomadTips />
<FeedbackWidget />
<OnboardingTour />

View File

@ -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<MemberMapItem[]>([]);
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 (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/report">{t.report.title}</Link>
</nav>
<div className="section-header reveal">
<span className="section-tag">{t.mapPage.tag}</span>
<h1>{t.mapPage.title}</h1>
<p>{t.mapPage.subtitle}</p>
</div>
{loading && <p className="dest-empty">加载地图…</p>}
{!loading && members.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">暂无公开会员分布</p>
<div className="dating-empty-actions">
<Link href="/dating" className="btn btn-primary btn-sm">
去匹配
</Link>
<Link href="/meetups" className="btn btn-sm">
去活动
</Link>
</div>
</div>
)}
<div className="member-map-grid reveal">
{members.map((m) => (
<div key={m.name} className="member-map-item" style={{ gridColumn: `span 1` }}>
<div key={m.name + m.city} className="member-map-item">
<span className="chat-avatar">{m.emoji}</span>
<div>
<strong>{m.name}</strong>
@ -36,9 +60,11 @@ export default function MemberMapClient() {
</div>
))}
</div>
{weather.length > 0 && (
<section className="weather-strip reveal">
<h3>{t.mapPage.weather}</h3>
<section className="weather-strip reveal">
<h3>{t.mapPage.weather}</h3>
{!loading && weather.length === 0 ? (
<p className="dest-empty">天气暂不可用</p>
) : (
<div className="weather-cards">
{weather.map((w) => (
<div key={w.name} className="weather-card">
@ -47,9 +73,10 @@ export default function MemberMapClient() {
</div>
))}
</div>
</section>
)}
)}
</section>
<p className="map-hint reveal">{t.mapPage.hint}</p>
<RingNext steps={rings.afterDiscover} />
</div>
</div>
);

View File

@ -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<void> } | 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<void> };
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 (
<div className="pwa-install-hint" role="status">
<p>{t.pwa.installHint}</p>
<div className="pwa-install-actions">
<button type="button" className="btn btn-primary btn-sm" onClick={() => void install()}>
安装
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={dismiss}>
稍后再说
</button>
</div>
</div>
);
}

View File

@ -121,7 +121,11 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
},
{ 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<string, StaticPageDef> = {
{ 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" }],
},
};