Polish connect-ring UX: VIP, notifications, dating, gigs, chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 20:16:31 -05:00
parent 8b80d18006
commit d43005d04a
11 changed files with 407 additions and 75 deletions

View File

@ -72,9 +72,9 @@ body {
} }
/* /*
* 任何祖先上的 overflow-x: hidden|clip 都会废掉 sticky 顶栏 * ?????? overflow-x: hidden|clip ???? sticky ??
*(滚一下顶栏整块飞走,只剩顶部残影 —— 用户截图那个状态)。 *????????????????? ?? ??????????
* ebook 路由 html/body 全部 visible。 * ebook ?? html/body ?? visible?
*/ */
html:has([data-ebook-root]) { html:has([data-ebook-root]) {
overflow-x: visible; overflow-x: visible;
@ -1234,7 +1234,7 @@ img { max-width: 100%; display: block; }
transform: translateY(0); transform: translateY(0);
} }
/* Sub-pages use SiteShell, not HomeClient — ensure content is never stuck hidden */ /* Sub-pages use SiteShell, not HomeClient ? ensure content is never stuck hidden */
:not(.home-flow) .reveal { :not(.home-flow) .reveal {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@ -2620,6 +2620,14 @@ img { max-width: 100%; display: block; }
white-space: nowrap; white-space: nowrap;
} }
.nav-user-avatar-img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.nav-logout { .nav-logout {
background: none; background: none;
border: none; border: none;
@ -2730,6 +2738,28 @@ img { max-width: 100%; display: block; }
font-weight: 600; font-weight: 600;
} }
.profile-badges-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.profile-badges-row .profile-level {
margin-top: 0;
}
.profile-vip-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 100px;
background: rgba(255, 193, 7, 0.18);
color: #e6b800;
font-size: 0.8rem;
font-weight: 600;
}
.profile-stats-grid { .profile-stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -9784,7 +9814,7 @@ img { max-width: 100%; display: block; }
.compare-bar .btn { flex: 1; min-width: 0; } .compare-bar .btn { flex: 1; min-width: 0; }
} }
/* ===== Next Stop / Meetups / Community (NomadCNA → nomadro) ===== */ /* ===== Next Stop / Meetups / Community (NomadCNA ? nomadro) ===== */
.next-stop-page, .next-stop-page,
.meetups-page, .meetups-page,
@ -10301,6 +10331,44 @@ img { max-width: 100%; display: block; }
margin-bottom: 20px; margin-bottom: 20px;
} }
.dating-quota a {
color: var(--accent-3);
}
.dating-empty,
.gigs-empty,
.notif-empty {
text-align: center;
padding: 1.5rem 0 0.5rem;
}
.dating-empty-actions,
.notif-empty-links,
.join-paid-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
justify-content: center;
margin-top: 1rem;
}
.join-vip-badge {
margin-top: 0.75rem;
display: inline-block;
padding: 0.35rem 0.9rem;
border-radius: 999px;
background: rgba(255, 193, 7, 0.15);
color: #e6b800;
font-size: 0.85rem;
font-weight: 600;
}
.gigs-applied {
color: var(--accent-3);
font-weight: 600;
margin: 0.5rem 0 0;
}
.dating-card { .dating-card {
max-width: 420px; max-width: 420px;
margin: 0 auto; margin: 0 auto;
@ -10569,6 +10637,13 @@ img { max-width: 100%; display: block; }
.dating-like-photo { font-size: 2.5rem; margin-bottom: 8px; } .dating-like-photo { font-size: 2.5rem; margin-bottom: 8px; }
.chat-avatar { font-size: 1.75rem; } .chat-avatar { font-size: 1.75rem; }
.chat-avatar-img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.chat-unread { .chat-unread {
margin-left: auto; margin-left: auto;
font-style: normal; font-style: normal;

View File

@ -43,6 +43,7 @@ export default function ProfilePage() {
const [meta, setMeta] = useState<PlanMeta | null>(null); const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [vip, setVip] = useState(false);
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -79,9 +80,11 @@ export default function ProfilePage() {
Promise.all([ Promise.all([
api.getProfileStats(token), api.getProfileStats(token),
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]), favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
]).then(([s, f]) => { api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })),
]).then(([s, f, v]) => {
setStats(s); setStats(s);
setFavoriteDests(f); setFavoriteDests(f);
setVip(Boolean(v.vip));
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
refreshPlan(); refreshPlan();
@ -141,7 +144,10 @@ export default function ProfilePage() {
<div className="profile-info"> <div className="profile-info">
<h1>{user.name}</h1> <h1>{user.name}</h1>
<p>{user.email}</p> <p>{user.email}</p>
{stats && <span className="profile-level">{stats.nomad_level}</span>} <div className="profile-badges-row">
{stats && <span className="profile-level">{stats.nomad_level}</span>}
{vip && <span className="profile-vip-badge">✨ VIP</span>}
</div>
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}> <span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
{planSyncStatus === "syncing" && "计划同步中…"} {planSyncStatus === "syncing" && "计划同步中…"}
{planSyncStatus === "synced" && "✓ 计划已云端同步"} {planSyncStatus === "synced" && "✓ 计划已云端同步"}

View File

@ -6,15 +6,37 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { ConversationItem } from "@/lib/types"; import type { ConversationItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
function Avatar({ photo, name }: { photo?: string; name?: string }) {
if (photo?.startsWith("http")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo} alt="" className="chat-avatar-img" />
);
}
return <span className="chat-avatar">{photo || name?.[0] || "💬"}</span>;
}
export default function ChatClient() { export default function ChatClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [items, setItems] = useState<ConversationItem[]>([]); const [items, setItems] = useState<ConversationItem[]>([]);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) {
api.getConversations(token).then(setItems).catch(() => setItems([])); setLoading(false);
return;
}
setLoading(true);
api
.getConversations(token)
.then(setItems)
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [token]); }, [token]);
if (!user) { if (!user) {
@ -41,18 +63,32 @@ export default function ChatClient() {
<h1>{t.chat.title}</h1> <h1>{t.chat.title}</h1>
</div> </div>
<div className="chat-list"> <div className="chat-list">
{items.map((c) => ( {loading && <p className="dest-empty">加载对话…</p>}
<Link key={c.id} href={`/chat/${c.id}`} className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}> {!loading &&
<span className="chat-avatar">{c.peer?.photo || "💬"}</span> items.map((c) => (
<div> <Link
<strong>{c.peer?.name || "游民"}</strong> key={c.id}
<p>{c.lastMessagePreview || t.chat.noMessages}</p> href={`/chat/${c.id}`}
</div> className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>} >
</Link> <Avatar photo={c.peer?.photo} name={c.peer?.name} />
))} <div>
{items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>} <strong>{c.peer?.name || "游民"}</strong>
<p>{c.lastMessagePreview || t.chat.noMessages}</p>
</div>
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
</Link>
))}
{!loading && items.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.chat.empty}</p>
<Link href="/dating" className="btn btn-primary btn-sm">
去匹配
</Link>
</div>
)}
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -123,7 +123,12 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="dest-empty">{t.community.empty}</p> <div className="notif-empty reveal">
<p className="dest-empty">{t.community.empty}</p>
<Link href="/community/new" className="btn btn-primary btn-sm">
{t.community.newTopic}
</Link>
</div>
)} )}
<RingNext steps={rings.afterCommunity} /> <RingNext steps={rings.afterCommunity} />

View File

@ -144,6 +144,12 @@ export default function DatingClient() {
{quota && ( {quota && (
<p className="dating-quota reveal"> <p className="dating-quota reveal">
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`} {quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
{!quota.vip && quota.remaining <= 0 && (
<>
{" · "}
<Link href="/join">开通 VIP 无限匹配</Link>
</>
)}
</p> </p>
)} )}
@ -154,6 +160,8 @@ export default function DatingClient() {
</div> </div>
)} )}
{loading && joined && <p className="dest-empty reveal">加载候选人…</p>}
{top && ( {top && (
<div className="dating-card reveal"> <div className="dating-card reveal">
<div className="dating-card-photo">{top.photo || "🧑‍💻"}</div> <div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
@ -164,9 +172,9 @@ export default function DatingClient() {
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)} {(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
</div> </div>
<div className="dating-actions"> <div className="dating-actions">
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")}>✕</button> <button type="button" className="dating-btn pass" onClick={() => swipe("dislike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>✕</button>
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")}>⭐</button> <button type="button" className="dating-btn super" onClick={() => swipe("superlike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>⭐</button>
<button type="button" className="dating-btn like" onClick={() => swipe("like")}>♥</button> <button type="button" className="dating-btn like" onClick={() => swipe("like")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>♥</button>
{token && ( {token && (
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button> <button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button>
)} )}
@ -175,7 +183,15 @@ export default function DatingClient() {
)} )}
{!loading && joined && !top && ( {!loading && joined && !top && (
<p className="dest-empty reveal">{t.dating.empty}</p> <div className="dating-empty reveal">
<p className="dest-empty">{t.dating.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => void load()}>刷新</button>
<Link href="/meetups" className="btn btn-sm">去活动认识人</Link>
<Link href="/chat" className="btn btn-sm">查看对话</Link>
{quota && !quota.vip && <Link href="/join" className="btn btn-primary btn-sm">开通 VIP</Link>}
</div>
</div>
)} )}
<RingNext steps={rings.afterDating} /> <RingNext steps={rings.afterDating} />

View File

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
@ -12,23 +13,33 @@ import { useRingSteps } from "@/lib/rings";
export default function GigsClient({ gigs }: { gigs: GigItem[] }) { export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [msg, setMsg] = useState<Record<string, string>>({}); const [msg, setMsg] = useState<Record<string, string>>({});
const [applied, setApplied] = useState<Record<string, boolean>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const apply = async (id: string) => { const apply = async (id: string) => {
if (!token) { if (!token) {
toast(t.gigs.loginFirst, "info"); router.push("/login?next=/gigs");
return; return;
} }
const message = msg[id]?.trim(); const message = msg[id]?.trim();
if (!message) return; if (!message) {
toast("请先填写申请说明", "info");
return;
}
setBusyId(id);
try { try {
await api.applyGig(token, id, message); await api.applyGig(token, id, message);
setApplied((a) => ({ ...a, [id]: true }));
toast(t.gigs.applyOk, "success"); toast(t.gigs.applyOk, "success");
} catch { } catch {
toast(t.gigs.applyFail, "error"); toast(t.gigs.applyFail, "error");
} finally {
setBusyId(null);
} }
}; };
@ -42,17 +53,46 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
<p>{t.gigs.subtitle}</p> <p>{t.gigs.subtitle}</p>
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link> <Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
</div> </div>
<div className="digital-jobs-grid reveal"> {gigs.length === 0 ? (
{gigs.map((g) => ( <div className="gigs-empty reveal">
<article key={g.id} className="digital-job-card"> <p className="dest-empty">暂时没有开放任务</p>
<h3>{g.title}</h3> <p className="section-desc">你可以发布一条赏金,或先去学院看看远程岗位</p>
<p>{g.description}</p> <div className="dating-empty-actions">
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p> <Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
<textarea rows={2} placeholder={t.gigs.applyPlaceholder} value={msg[g.id] || ""} onChange={(e) => setMsg({ ...msg, [g.id]: e.target.value })} /> <Link href="/digital" className="btn btn-sm">{t.nav.digital}</Link>
<button type="button" className="btn btn-primary btn-sm" onClick={() => apply(g.id)}>{t.gigs.apply}</button> </div>
</article> </div>
))} ) : (
</div> <div className="digital-jobs-grid reveal">
{gigs.map((g) => (
<article key={g.id} className="digital-job-card">
<h3>{g.title}</h3>
<p>{g.description}</p>
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p>
{applied[g.id] ? (
<p className="gigs-applied">✓ 已申请</p>
) : (
<>
<textarea
rows={2}
placeholder={t.gigs.applyPlaceholder}
value={msg[g.id] || ""}
onChange={(e) => setMsg({ ...msg, [g.id]: e.target.value })}
/>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyId === g.id}
onClick={() => apply(g.id)}
>
{busyId === g.id ? "提交中…" : t.gigs.apply}
</button>
</>
)}
</article>
))}
</div>
)}
<RingNext steps={rings.afterGigs} /> <RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>

View File

@ -1,21 +1,40 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, 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";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function JoinClient() { export default function JoinClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [city, setCity] = useState("清迈"); const [city, setCity] = useState("清迈");
const [bio, setBio] = useState(""); const [bio, setBio] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [vip, setVip] = useState(false);
const [checkingVip, setCheckingVip] = useState(Boolean(token));
useEffect(() => {
if (!token) {
setVip(false);
setCheckingVip(false);
return;
}
setCheckingVip(true);
api
.getVipStatus(token)
.then((s) => setVip(Boolean(s.vip)))
.catch(() => setVip(false))
.finally(() => setCheckingVip(false));
}, [token]);
const submit = async () => { const submit = async () => {
if (!token) { if (!token) {
@ -38,13 +57,27 @@ export default function JoinClient() {
router.push("/login?next=/join"); router.push("/login?next=/join");
return; return;
} }
if (vip) {
router.push("/dating");
return;
}
setLoading(true); setLoading(true);
try { try {
const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join"); const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join");
localStorage.setItem("nomadro-pay-order", res.order_id); if (res.order_id) localStorage.setItem("nomadro-pay-order", res.order_id);
if (res.status === "paid" && res.order_id) {
await api.completePayment(token, res.order_id).catch(() => null);
router.push(`/join/paid?order_id=${encodeURIComponent(res.order_id)}`);
return;
}
if (!res.redirect_url) {
toast(t.join.payFail, "error");
setLoading(false);
return;
}
window.location.href = res.redirect_url; window.location.href = res.redirect_url;
} catch { } catch (e) {
toast(t.join.payFail, "error"); toast(e instanceof Error ? e.message : t.join.payFail, "error");
setLoading(false); setLoading(false);
} }
}; };
@ -57,6 +90,9 @@ export default function JoinClient() {
<span className="section-tag">{t.join.tag}</span> <span className="section-tag">{t.join.tag}</span>
<h1>{t.join.title}</h1> <h1>{t.join.title}</h1>
<p>{t.join.subtitle}</p> <p>{t.join.subtitle}</p>
{!checkingVip && vip && (
<p className="join-vip-badge" role="status">✨ 你已是 VIP · 权益已生效</p>
)}
</div> </div>
<div className="join-grid reveal"> <div className="join-grid reveal">
@ -79,12 +115,14 @@ export default function JoinClient() {
<li>🎙️ {t.join.perk3}</li> <li>🎙️ {t.join.perk3}</li>
<li>📚 {t.join.perk4}</li> <li>📚 {t.join.perk4}</li>
</ul> </ul>
<button type="button" className="btn btn-primary" disabled={loading} onClick={pay}> <button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={pay}>
{t.join.payBtn} {vip ? t.join.goDating : t.join.payBtn}
</button> </button>
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>} {!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
</div> </div>
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -11,27 +11,47 @@ export default function JoinPaidClient() {
const { token } = useAuth(); const { token } = useAuth();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useI18n(); const { t } = useI18n();
const [status, setStatus] = useState<"loading" | "ok" | "fail">("loading"); const [status, setStatus] = useState<"loading" | "ok" | "fail" | "need-login">("loading");
useEffect(() => { useEffect(() => {
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order"); if (!token) {
if (!token || !orderId) { setStatus("need-login");
setStatus("fail");
return; return;
} }
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order") || "";
let cancelled = false;
let tries = 0; let tries = 0;
const run = async () => { const run = async () => {
try { try {
await api.completePayment(token, orderId); if (orderId) {
localStorage.removeItem("nomadro-pay-order"); await api.completePayment(token, orderId);
setStatus("ok"); if (!cancelled) {
localStorage.removeItem("nomadro-pay-order");
setStatus("ok");
}
return;
}
const vip = await api.getVipStatus(token);
if (!cancelled) setStatus(vip.vip ? "ok" : "fail");
} catch { } catch {
tries += 1; tries += 1;
if (tries < 5) setTimeout(run, 800); if (tries < 5) {
else setStatus("fail"); setTimeout(run, 900);
} else if (!cancelled) {
try {
const vip = await api.getVipStatus(token);
setStatus(vip.vip ? "ok" : "fail");
} catch {
setStatus("fail");
}
}
} }
}; };
void run(); void run();
return () => {
cancelled = true;
};
}, [token, searchParams]); }, [token, searchParams]);
return ( return (
@ -39,11 +59,20 @@ export default function JoinPaidClient() {
<div className="container"> <div className="container">
<div className="join-paid reveal"> <div className="join-paid reveal">
{status === "loading" && <p>{t.join.paidLoading}</p>} {status === "loading" && <p>{t.join.paidLoading}</p>}
{status === "need-login" && (
<>
<h2>{t.join.paidFail}</h2>
<Link href="/login?next=/join/paid" className="btn btn-primary">{t.nav.login}</Link>
</>
)}
{status === "ok" && ( {status === "ok" && (
<> <>
<h2>🎉 {t.join.paidOk}</h2> <h2>🎉 {t.join.paidOk}</h2>
<Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link> <div className="join-paid-actions">
<Link href="/digital" className="btn">{t.join.goDigital}</Link> <Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link>
<Link href="/digital" className="btn">{t.join.goDigital}</Link>
<Link href="/meetups" className="btn btn-ghost">去活动</Link>
</div>
</> </>
)} )}
{status === "fail" && ( {status === "fail" && (

View File

@ -27,7 +27,17 @@ export default function Navbar() {
setUnread(0); setUnread(0);
return; return;
} }
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0)); const refresh = () => {
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
};
refresh();
const id = window.setInterval(refresh, 60000);
const onFocus = () => refresh();
window.addEventListener("focus", onFocus);
return () => {
window.clearInterval(id);
window.removeEventListener("focus", onFocus);
};
}, [token, pathname]); }, [token, pathname]);
const explore: NavItem[] = [ const explore: NavItem[] = [
@ -238,7 +248,12 @@ export default function Navbar() {
)} )}
{user ? ( {user ? (
<Link href="/profile" className="nav-user nav-user-desktop"> <Link href="/profile" className="nav-user nav-user-desktop">
<span>{user.avatar}</span> {user.avatar?.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar} alt="" className="nav-user-avatar-img" />
) : (
<span>{user.avatar || "🧑‍💻"}</span>
)}
<span className="nav-user-name">{user.name}</span> <span className="nav-user-name">{user.name}</span>
</Link> </Link>
) : ( ) : (

View File

@ -6,28 +6,55 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { NotificationItem } from "@/lib/types"; import type { NotificationItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function NotificationsClient() { export default function NotificationsClient() {
const { token } = useAuth(); const { token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [items, setItems] = useState<NotificationItem[]>([]); const [items, setItems] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) {
api.getNotifications(token).then(setItems).catch(() => setItems([])); setItems([]);
setLoading(false);
return;
}
setLoading(true);
api
.getNotifications(token)
.then(setItems)
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [token]); }, [token]);
const markAll = async () => { const markAll = async () => {
if (!token) return; if (!token) return;
await api.markNotificationsRead(token); await api.markNotificationsRead(token).catch(() => null);
setItems((list) => list.map((n) => ({ ...n, read: true }))); setItems((list) => list.map((n) => ({ ...n, read: true })));
}; };
const openOne = async (n: NotificationItem) => {
if (!token || n.read) return;
await api.notificationAction(token, n.id, "read").catch(() => null);
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
};
if (!token) { if (!token) {
return ( return (
<div className="chat-page"><div className="container"> <div className="chat-page">
<Link href="/login?next=/notifications" className="btn btn-primary">{t.nav.login}</Link> <div className="container">
</div></div> <div className="dating-gate reveal">
<h2>{t.notifications.title}</h2>
<p>登录后查看匹配、报名与回复提醒</p>
<Link href="/login?next=/notifications" className="btn btn-primary">
{t.nav.login}
</Link>
</div>
</div>
</div>
); );
} }
@ -37,19 +64,61 @@ export default function NotificationsClient() {
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.notifications.title}</h1> <h1>{t.notifications.title}</h1>
<div className="notif-toolbar"> <div className="notif-toolbar">
<button type="button" className="btn btn-sm" onClick={markAll}>{t.notifications.markRead}</button> <button type="button" className="btn btn-sm" onClick={markAll} disabled={items.every((n) => n.read)}>
<Link href="/settings/notifications" className="btn btn-sm">{t.notifSettings.title}</Link> {t.notifications.markRead}
</button>
<Link href="/settings/notifications" className="btn btn-sm">
{t.notifSettings.title}
</Link>
</div> </div>
</div> </div>
<div className="chat-list"> <div className="chat-list">
{items.map((n) => ( {loading && <p className="dest-empty">加载中…</p>}
<div key={n.id} className={`chat-list-item reveal${n.read ? "" : " unread"}`}> {!loading &&
<div><strong>{n.title}</strong><p>{n.body}</p><small>{n.created_at}</small></div> items.map((n) => (
{n.link && <Link href={n.link}>{t.common.open}</Link>} <div key={n.id} className={`chat-list-item reveal${n.read ? "" : " unread"}`}>
<div>
<strong>{n.title}</strong>
<p>{n.body}</p>
<small>{n.created_at}</small>
</div>
{n.link ? (
<Link
href={n.link}
onClick={() => {
void openOne(n);
}}
>
{t.common.open}
</Link>
) : (
!n.read && (
<button type="button" className="btn btn-sm" onClick={() => void openOne(n)}>
标为已读
</button>
)
)}
</div>
))}
{!loading && items.length === 0 && (
<div className="notif-empty reveal">
<p className="dest-empty">{t.notifications.empty}</p>
<p className="section-desc">去互动一下,通知会出现在这里</p>
<div className="notif-empty-links">
<Link href="/meetups" className="btn btn-sm">
活动
</Link>
<Link href="/dating" className="btn btn-sm">
匹配
</Link>
<Link href="/community" className="btn btn-sm">
社区
</Link>
</div>
</div> </div>
))} )}
{items.length === 0 && <p className="dest-empty">{t.notifications.empty}</p>}
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -8,6 +8,7 @@ PocketBase is expected as a host process on :8090.
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
import time import time
@ -22,6 +23,7 @@ GIT = "https://eric:Xiao4669805@gitea.dsx2020.com/eric/nomadweb.git"
DOMAIN = "nomadweb.nomadro.com" DOMAIN = "nomadweb.nomadro.com"
REMOTE = "/opt/nomadweb" REMOTE = "/opt/nomadweb"
MARKER = f"{REMOTE}/.last_deploy_sha" MARKER = f"{REMOTE}/.last_deploy_sha"
BRANCH = os.environ.get("DEPLOY_BRANCH", "dev2")
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 900) -> tuple[int, str, str]: def run(client: paramiko.SSHClient, cmd: str, timeout: int = 900) -> tuple[int, str, str]:
@ -53,10 +55,10 @@ MARKER="__MARKER__"
cd "$REMOTE" cd "$REMOTE"
git remote set-url origin "$GIT" git remote set-url origin "$GIT"
git fetch --depth=80 origin dev1 git fetch --depth=80 origin __BRANCH__
# Force-align to origin/dev1 even if prior SFTP hotfixes left dirty trees # Force-align to origin/__BRANCH__ even if prior SFTP hotfixes left dirty trees
git checkout -f -B dev1 origin/dev1 git checkout -f -B __BRANCH__ origin/__BRANCH__
git reset --hard origin/dev1 git reset --hard origin/__BRANCH__
git clean -fd \ git clean -fd \
-e deploy/nomadro-api.env \ -e deploy/nomadro-api.env \
-e backend/app/data/user_store.json \ -e backend/app/data/user_store.json \
@ -199,6 +201,7 @@ def main() -> int:
.replace("__GIT__", GIT) .replace("__GIT__", GIT)
.replace("__REMOTE__", REMOTE) .replace("__REMOTE__", REMOTE)
.replace("__MARKER__", MARKER) .replace("__MARKER__", MARKER)
.replace("__BRANCH__", BRANCH)
) )
code, out, _ = run( code, out, _ = run(