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 = [ const LOGS = [
{
date: "2026-08-30",
tag: "三环可用深化",
items: [
"产品信息架构:Explore / Connect / Grow 渐进披露,导航与空态对齐三条环",
"Connect:RSVP 需登录、通知铃铛、匹配配额、私信与 VIP 支付加固",
"Grow:学院 VIP 课时客户端解锁、赏金/投稿表单校验、电子书购买闭环",
"Explore:目的地对比入口、AI 快捷提问、计划空态、周报订阅诚实提示",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "电子书融合", tag: "电子书融合",

View File

@ -14,15 +14,42 @@ export default async function DigitalDayPage({ params }: { params: Promise<{ id:
const { id } = await params; const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null); const day = await api.getDigitalDay(id).catch(() => null);
if (!day) notFound(); 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 ( return (
<SiteShell showFooter> <SiteShell showFooter>
<div className="legal-page"> <div className="digital-page">
<nav className="detail-nav"><Link href="/digital">← 游民学院</Link></nav> <div className="container" style={{ maxWidth: 720 }}>
<article className="legal-content reveal"> <nav className="detail-nav">
<span className="section-tag">📅 DAY {id}</span> <Link href="/digital">← 游民学院</Link>
<h1>{day.title}</h1> </nav>
<p>{day.body}</p> <article className="digital-lesson reveal">
</article> <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> </div>
</SiteShell> </SiteShell>
); );

View File

@ -5688,6 +5688,12 @@ img { max-width: 100%; display: block; }
padding: 4px; padding: 4px;
} }
.auth-pwd-hint {
margin-top: 6px;
font-size: 0.78rem;
color: var(--text-muted);
}
.auth-submit { .auth-submit {
width: 100%; width: 100%;
} }
@ -6850,6 +6856,32 @@ img { max-width: 100%; display: block; }
.phrase-zh { font-size: 0.85rem; color: var(--text-secondary); } .phrase-zh { font-size: 0.85rem; color: var(--text-secondary); }
/* ===== Feedback Widget ===== */ /* ===== 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 { .feedback-fab {
position: fixed; position: fixed;
bottom: 100px; bottom: 100px;
@ -10919,8 +10951,25 @@ img { max-width: 100%; display: block; }
@media (max-width: 768px) { @media (max-width: 768px) {
.join-grid { grid-template-columns: 1fr; } .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-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 -- */ /* -- Feature rings / progressive IA -- */

View File

@ -34,10 +34,20 @@ function LoginForm() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (mode === "register") {
if (!name.trim() || name.trim().length < 2) {
setError("昵称至少 2 个字符");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
}
setLoading(true); setLoading(true);
const ok = mode === "login" const ok = mode === "login"
? await login(email, password) ? await login(email, password)
: await register(email, password, name); : await register(email, password, name.trim());
setLoading(false); setLoading(false);
if (ok) { if (ok) {
goAfterAuth( goAfterAuth(
@ -116,6 +126,17 @@ function LoginForm() {
{showPwd ? "🙈" : "👁️"} {showPwd ? "🙈" : "👁️"}
</button> </button>
</div> </div>
{mode === "register" && (
<p className="auth-pwd-hint">
{password.length === 0
? "至少 6 位,建议包含字母与数字"
: password.length < 6
? `还差 ${6 - password.length} 位`
: password.length < 10
? "可用 · 再长一点更安全"
: "强度不错"}
</p>
)}
</div> </div>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}> <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"> <div className="cookie-consent-inner">
<span className="cookie-emoji">🍪</span> <span className="cookie-emoji">🍪</span>
<p> <p>
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。 我们使用本地存储保存主题、行程与登录会话,不做广告追踪。
<Link href="/privacy">隐私政策</Link> <Link href="/privacy">隐私政策</Link>
{" · "}
<Link href="/cookies">Cookie 说明</Link>
</p> </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>
</div> </div>
); );

View File

@ -113,7 +113,30 @@ export default function Destinations({ destinations, onOpenMatcher }: Props) {
</div> </div>
{filtered.length === 0 && ( {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 && ( {canExpand && (

View File

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

View File

@ -33,6 +33,7 @@ const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget")); const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
const OnboardingTour = lazy(() => import("./OnboardingTour")); const OnboardingTour = lazy(() => import("./OnboardingTour"));
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts")); const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
const PwaInstallHint = lazy(() => import("./PwaInstallHint"));
interface Props { interface Props {
stats: Stats; stats: Stats;
@ -139,6 +140,7 @@ export default function HomeClient(props: Props) {
<> <>
<KeyboardShortcuts onOpenMatcher={openMatcher} /> <KeyboardShortcuts onOpenMatcher={openMatcher} />
<CookieConsent /> <CookieConsent />
<PwaInstallHint />
<NomadTips /> <NomadTips />
<FeedbackWidget /> <FeedbackWidget />
<OnboardingTour /> <OnboardingTour />

View File

@ -5,29 +5,53 @@ import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MemberMapItem } from "@/lib/types"; import type { MemberMapItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function MemberMapClient() { export default function MemberMapClient() {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [members, setMembers] = useState<MemberMapItem[]>([]); const [members, setMembers] = useState<MemberMapItem[]>([]);
const [weather, setWeather] = useState<{ name: string; temperature: number | null }[]>([]); const [weather, setWeather] = useState<{ name: string; temperature: number | null }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {}); Promise.all([
api.getWeatherCities().then(setWeather).catch(() => {}); api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => setMembers([])),
api.getWeatherCities().then(setWeather).catch(() => setWeather([])),
]).finally(() => setLoading(false));
}, []); }, []);
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <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"> <div className="section-header reveal">
<span className="section-tag">{t.mapPage.tag}</span> <span className="section-tag">{t.mapPage.tag}</span>
<h1>{t.mapPage.title}</h1> <h1>{t.mapPage.title}</h1>
<p>{t.mapPage.subtitle}</p> <p>{t.mapPage.subtitle}</p>
</div> </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"> <div className="member-map-grid reveal">
{members.map((m) => ( {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> <span className="chat-avatar">{m.emoji}</span>
<div> <div>
<strong>{m.name}</strong> <strong>{m.name}</strong>
@ -36,9 +60,11 @@ export default function MemberMapClient() {
</div> </div>
))} ))}
</div> </div>
{weather.length > 0 && ( <section className="weather-strip reveal">
<section className="weather-strip reveal"> <h3>{t.mapPage.weather}</h3>
<h3>{t.mapPage.weather}</h3> {!loading && weather.length === 0 ? (
<p className="dest-empty">天气暂不可用</p>
) : (
<div className="weather-cards"> <div className="weather-cards">
{weather.map((w) => ( {weather.map((w) => (
<div key={w.name} className="weather-card"> <div key={w.name} className="weather-card">
@ -47,9 +73,10 @@ export default function MemberMapClient() {
</div> </div>
))} ))}
</div> </div>
</section> )}
)} </section>
<p className="map-hint reveal">{t.mapPage.hint}</p> <p className="map-hint reveal">{t.mapPage.hint}</p>
<RingNext steps={rings.afterDiscover} />
</div> </div>
</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,标题注明「应聘 - 岗位名」。" }, { heading: "投递", body: "hello@nomadro.com,标题注明「应聘 - 岗位名」。" },
], ],
cta: [{ label: "赏金任务", href: "/gigs" }], cta: [
{ label: "赏金任务", href: "/gigs" },
{ label: "内容投稿", href: "/submit" },
{ label: "联系邮箱", href: "mailto:hello@nomadro.com" },
],
}, },
support: { support: {
tag: "🤝 SUPPORT", tag: "🤝 SUPPORT",
@ -130,7 +134,13 @@ export const STATIC_PAGES: Record<string, StaticPageDef> = {
{ heading: "成为 VIP", body: "解锁匹配、直播、学院课程,支持平台持续运营。" }, { heading: "成为 VIP", body: "解锁匹配、直播、学院课程,支持平台持续运营。" },
{ heading: "分享", body: "推荐给朋友,或在社交媒体分享你的旅居故事。" }, { heading: "分享", 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" }],
}, },
}; };