Polish map empty states, login hints, PWA install, live mobile.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9f2eee7e83
commit
dac99e8ef4
@ -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: "电子书融合",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 -- */
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
55
frontend/src/components/PwaInstallHint.tsx
Normal file
55
frontend/src/components/PwaInstallHint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" }],
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user