Polish destination actions, submit, likes, newsletter honesty.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ce48023ae
commit
9f2eee7e83
@ -64,6 +64,16 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="blog-related" style={{ marginTop: "2rem" }}>
|
||||||
|
<h2>接着探索</h2>
|
||||||
|
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
|
||||||
|
<Link href="/community" className="btn btn-primary btn-sm">社区讨论</Link>
|
||||||
|
<Link href="/book" className="btn btn-sm">电子书</Link>
|
||||||
|
<Link href="/next-stop" className="btn btn-sm">下一站</Link>
|
||||||
|
<Link href="/submit" className="btn btn-ghost btn-sm">投稿</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4532,6 +4532,12 @@ img { max-width: 100%; display: block; }
|
|||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dest-depth-loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dest-detail-extras {
|
.dest-detail-extras {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.2fr 1fr;
|
grid-template-columns: 1.2fr 1fr;
|
||||||
@ -10641,6 +10647,12 @@ img { max-width: 100%; display: block; }
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.newsletter-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.report-grid { grid-template-columns: 1fr; }
|
.report-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,7 +104,27 @@ export default function ToolsHubPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.length === 0 && <p className="dest-empty">没有匹配的工具,试试其他关键词</p>}
|
{filtered.length === 0 && (
|
||||||
|
<div className="notif-empty">
|
||||||
|
<p className="dest-empty">没有匹配的工具,试试其他关键词</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
setFilter("all");
|
||||||
|
setShowAll(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
<Link href="/next-stop" className="btn btn-primary btn-sm">
|
||||||
|
去下一站
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@ -17,43 +17,102 @@ export default function ContentSubmitClient() {
|
|||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [contentType, setContentType] = useState("article");
|
const [contentType, setContentType] = useState("article");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/login?next=/submit");
|
router.push("/login?next=/submit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!title.trim() || title.trim().length < 4) {
|
||||||
|
toast("请填写更完整的标题", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.submitContent({ title, url, notes, content_type: contentType, name: "" }, token);
|
await api.submitContent(
|
||||||
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
notes: notes.trim(),
|
||||||
|
content_type: contentType,
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
toast(t.submit.ok, "success");
|
toast(t.submit.ok, "success");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setUrl("");
|
setUrl("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setDone(true);
|
||||||
} catch {
|
} catch {
|
||||||
toast(t.submit.fail, "error");
|
toast(t.submit.fail, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(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>
|
||||||
|
</nav>
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">{t.submit.tag}</span>
|
<span className="section-tag">{t.submit.tag}</span>
|
||||||
<h1>{t.submit.title}</h1>
|
<h1>{t.submit.title}</h1>
|
||||||
<p>{t.submit.subtitle}</p>
|
<p>{t.submit.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{done ? (
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>投稿已收到</h2>
|
||||||
|
<p>审核通过后会出现在博客或视频区</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
|
||||||
|
再投一篇
|
||||||
|
</button>
|
||||||
|
<Link href="/community" className="btn">
|
||||||
|
去社区
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="community-compose reveal">
|
<div className="community-compose reveal">
|
||||||
<select value={contentType} onChange={(e) => setContentType(e.target.value)}>
|
<select value={contentType} onChange={(e) => setContentType(e.target.value)}>
|
||||||
<option value="article">{t.submit.article}</option>
|
<option value="article">{t.submit.article}</option>
|
||||||
<option value="video">{t.submit.video}</option>
|
<option value="video">{t.submit.video}</option>
|
||||||
<option value="ebook">{t.submit.ebook}</option>
|
<option value="ebook">{t.submit.ebook}</option>
|
||||||
</select>
|
</select>
|
||||||
<input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} />
|
<input
|
||||||
<input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} />
|
placeholder={t.submit.subject}
|
||||||
<textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
value={title}
|
||||||
<button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button>
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder={t.submit.url}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder={t.submit.notes}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
|
||||||
|
{busy ? "提交中…" : t.submit.send}
|
||||||
|
</button>
|
||||||
|
{!token && (
|
||||||
|
<p className="section-desc">
|
||||||
|
<Link href="/login?next=/submit">{t.nav.login}</Link> 后可提交
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,10 +11,19 @@ export default function DatingLikesClient() {
|
|||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [likes, setLikes] = useState<MatchProfile[]>([]);
|
const [likes, setLikes] = useState<MatchProfile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(Boolean(token));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.getMatchLikes(token)
|
||||||
|
.then(setLikes)
|
||||||
|
.catch(() => setLikes([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -23,7 +32,9 @@ export default function DatingLikesClient() {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="dating-gate reveal">
|
<div className="dating-gate reveal">
|
||||||
<h2>{t.dating.loginTitle}</h2>
|
<h2>{t.dating.loginTitle}</h2>
|
||||||
<Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link>
|
<Link href="/login?next=/dating/likes" className="btn btn-primary">
|
||||||
|
{t.nav.login}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,15 +52,35 @@ export default function DatingLikesClient() {
|
|||||||
<h1>{t.dating.likesTitle}</h1>
|
<h1>{t.dating.likesTitle}</h1>
|
||||||
<p>{t.dating.likesSubtitle}</p>
|
<p>{t.dating.likesSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="dating-likes-grid">
|
{loading && <p className="dest-empty">加载中…</p>}
|
||||||
{likes.map((p) => (
|
{!loading && likes.length === 0 && (
|
||||||
<div key={p.id} className="dating-like-card reveal">
|
<div className="notif-empty reveal">
|
||||||
<div className="dating-like-photo">{p.photo || "🧑💻"}</div>
|
<p className="dest-empty">{t.dating.likesEmpty}</p>
|
||||||
<strong>{p.name}</strong>
|
<Link href="/dating" className="btn btn-primary btn-sm">
|
||||||
<p className="dating-meta">{p.location}</p>
|
去滑动匹配
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
{likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>}
|
<div className="dating-likes-grid">
|
||||||
|
{!loading &&
|
||||||
|
likes.map((p) => {
|
||||||
|
const photo = p.photo || "";
|
||||||
|
const href = p.userId ? `/members/${p.userId}` : "/dating";
|
||||||
|
return (
|
||||||
|
<Link key={p.id} href={href} className="dating-like-card reveal">
|
||||||
|
<div className="dating-like-photo">
|
||||||
|
{photo.startsWith("http") ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={photo} alt="" className="member-photo-img" />
|
||||||
|
) : (
|
||||||
|
photo || "🧑💻"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<p className="dating-meta">{p.location || "全球"}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
const [content, setContent] = useState<CityContentItem[]>([]);
|
const [content, setContent] = useState<CityContentItem[]>([]);
|
||||||
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
||||||
const [discussions, setDiscussions] = useState<Discussion[]>([]);
|
const [discussions, setDiscussions] = useState<Discussion[]>([]);
|
||||||
|
const [depthLoading, setDepthLoading] = useState(true);
|
||||||
const scores = getScores(dest);
|
const scores = getScores(dest);
|
||||||
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
|
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
|
||||||
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
|
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
|
||||||
@ -51,12 +52,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getDestinationFull(dest.slug).then((full) => {
|
setDepthLoading(true);
|
||||||
|
api
|
||||||
|
.getDestinationFull(dest.slug)
|
||||||
|
.then((full) => {
|
||||||
setDetail(full.detail);
|
setDetail(full.detail);
|
||||||
setContent(full.content || []);
|
setContent(full.content || []);
|
||||||
setMeetups(full.meetups || []);
|
setMeetups(full.meetups || []);
|
||||||
setDiscussions(full.discussions || []);
|
setDiscussions(full.discussions || []);
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setDetail(null);
|
||||||
|
setContent([]);
|
||||||
|
setMeetups([]);
|
||||||
|
setDiscussions([]);
|
||||||
|
})
|
||||||
|
.finally(() => setDepthLoading(false));
|
||||||
}, [dest.slug]);
|
}, [dest.slug]);
|
||||||
|
|
||||||
const addToTrip = () => {
|
const addToTrip = () => {
|
||||||
@ -113,13 +124,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
/>
|
/>
|
||||||
月
|
月
|
||||||
</label>
|
</label>
|
||||||
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
<button type="button" className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">⚖️ 去对比</Link>
|
||||||
|
<Link href="/next-stop" className="btn btn-ghost">🧭 找下一站</Link>
|
||||||
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
|
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
|
||||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
<button type="button" className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{depthLoading && <p className="dest-depth-loading">加载城市深度信息…</p>}
|
||||||
|
|
||||||
{(visa || overBudget) && (
|
{(visa || overBudget) && (
|
||||||
<div className="dest-plan-hints">
|
<div className="dest-plan-hints">
|
||||||
{visa && (
|
{visa && (
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/lib/auth";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { loginNextFrom } from "@/lib/authNext";
|
||||||
|
|
||||||
export default function FavoriteButton({ slug }: { slug: string }) {
|
export default function FavoriteButton({ slug }: { slug: string }) {
|
||||||
const { user, isFavorite, toggleFavorite } = useAuth();
|
const { user, isFavorite, toggleFavorite } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const next = loginNextFrom(pathname || `/destinations/${slug}`);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}>
|
<Link
|
||||||
|
href={`/login?next=${encodeURIComponent(next)}`}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
🤍 登录收藏
|
🤍 登录收藏
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@ -16,8 +24,13 @@ export default function FavoriteButton({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
const fav = isFavorite(slug);
|
const fav = isFavorite(slug);
|
||||||
return (
|
return (
|
||||||
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
<button
|
||||||
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
|
type="button"
|
||||||
|
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => void toggleFavorite(slug)}
|
||||||
|
style={{ fontSize: "0.85rem" }}
|
||||||
|
aria-pressed={fav}
|
||||||
|
>
|
||||||
{fav ? "❤️ 已收藏" : "🤍 收藏"}
|
{fav ? "❤️ 已收藏" : "🤍 收藏"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const CORE_PAGES: SearchResult[] = [
|
|||||||
|
|
||||||
/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */
|
/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */
|
||||||
const EXTRA_PAGES: SearchResult[] = [
|
const EXTRA_PAGES: SearchResult[] = [
|
||||||
|
{ type: "page", title: "游牧代码电子书", subtitle: "在线阅读与下载版", emoji: "📖", url: "/book" },
|
||||||
{ type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
|
{ type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
|
||||||
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
|
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
|
||||||
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
|
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
|
||||||
|
|||||||
@ -9,15 +9,23 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!email.trim()) return;
|
const value = email.trim();
|
||||||
|
if (!value || !value.includes("@")) {
|
||||||
|
toast("请输入有效邮箱", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.subscribeNewsletter(email.trim(), source);
|
const res = await api.subscribeNewsletter(value, source);
|
||||||
toast(res.message || t.newsletter.ok, "success");
|
toast(res.message || t.newsletter.ok, "success");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
} catch {
|
} catch {
|
||||||
toast(t.newsletter.fail, "error");
|
toast(t.newsletter.fail, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,9 +34,19 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
|||||||
<strong>{t.newsletter.title}</strong>
|
<strong>{t.newsletter.title}</strong>
|
||||||
<p>{t.newsletter.subtitle}</p>
|
<p>{t.newsletter.subtitle}</p>
|
||||||
<div className="newsletter-form">
|
<div className="newsletter-form">
|
||||||
<input type="email" placeholder={t.newsletter.email} value={email} onChange={(e) => setEmail(e.target.value)} />
|
<input
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={submit}>{t.newsletter.submit}</button>
|
type="email"
|
||||||
|
placeholder={t.newsletter.email}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && void submit()}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void submit()}>
|
||||||
|
{busy ? "…" : t.newsletter.submit}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,29 +4,46 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
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 RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
export default function ReportClient() {
|
export default function ReportClient() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
|
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
|
||||||
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
|
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => {});
|
Promise.all([
|
||||||
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {});
|
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => setRanking([])),
|
||||||
|
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => setMembers([])),
|
||||||
|
]).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>
|
||||||
|
</nav>
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">{t.report.tag}</span>
|
<span className="section-tag">{t.report.tag}</span>
|
||||||
<h1>{t.report.title}</h1>
|
<h1>{t.report.title}</h1>
|
||||||
<p>{t.report.subtitle}</p>
|
<p>{t.report.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{loading && <p className="dest-empty">加载报告…</p>}
|
||||||
<div className="report-grid">
|
<div className="report-grid">
|
||||||
<section className="join-card reveal">
|
<section className="join-card reveal">
|
||||||
<h3>{t.report.ranking}</h3>
|
<h3>{t.report.ranking}</h3>
|
||||||
|
{!loading && ranking.length === 0 ? (
|
||||||
|
<div className="notif-empty">
|
||||||
|
<p className="dest-empty">排名数据暂不可用</p>
|
||||||
|
<Link href="/next-stop" className="btn btn-sm">
|
||||||
|
去智能下一站
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ol className="report-rank">
|
<ol className="report-rank">
|
||||||
{ranking.map((c, i) => (
|
{ranking.map((c, i) => (
|
||||||
<li key={c.slug}>
|
<li key={c.slug}>
|
||||||
@ -37,9 +54,13 @@ export default function ReportClient() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="join-card reveal">
|
<section className="join-card reveal">
|
||||||
<h3>{t.report.members}</h3>
|
<h3>{t.report.members}</h3>
|
||||||
|
{!loading && members.length === 0 ? (
|
||||||
|
<p className="dest-empty">会员分布暂不可用</p>
|
||||||
|
) : (
|
||||||
<div className="member-map-list">
|
<div className="member-map-list">
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<div key={m.name + m.city} className="member-map-item">
|
<div key={m.name + m.city} className="member-map-item">
|
||||||
@ -49,9 +70,13 @@ export default function ReportClient() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link href="/map" className="btn btn-sm">{t.report.viewMap}</Link>
|
)}
|
||||||
|
<Link href="/map" className="btn btn-sm">
|
||||||
|
{t.report.viewMap}
|
||||||
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<RingNext steps={rings.afterDiscover} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user