Polish connect-ring UX: VIP, notifications, dating, gigs, chat.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8b80d18006
commit
d43005d04a
@ -72,9 +72,9 @@ body {
|
||||
}
|
||||
|
||||
/*
|
||||
* 任何祖先上的 overflow-x: hidden|clip 都会废掉 sticky 顶栏
|
||||
*(滚一下顶栏整块飞走,只剩顶部残影 —— 用户截图那个状态)。
|
||||
* ebook 路由 html/body 全部 visible。
|
||||
* ?????? overflow-x: hidden|clip ???? sticky ??
|
||||
*????????????????? ?? ??????????
|
||||
* ebook ?? html/body ?? visible?
|
||||
*/
|
||||
html:has([data-ebook-root]) {
|
||||
overflow-x: visible;
|
||||
@ -1234,7 +1234,7 @@ img { max-width: 100%; display: block; }
|
||||
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 {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@ -2620,6 +2620,14 @@ img { max-width: 100%; display: block; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-user-avatar-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-logout {
|
||||
background: none;
|
||||
border: none;
|
||||
@ -2730,6 +2738,28 @@ img { max-width: 100%; display: block; }
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@ -9784,7 +9814,7 @@ img { max-width: 100%; display: block; }
|
||||
.compare-bar .btn { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
/* ===== Next Stop / Meetups / Community (NomadCNA → nomadro) ===== */
|
||||
/* ===== Next Stop / Meetups / Community (NomadCNA ? nomadro) ===== */
|
||||
|
||||
.next-stop-page,
|
||||
.meetups-page,
|
||||
@ -10301,6 +10331,44 @@ img { max-width: 100%; display: block; }
|
||||
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 {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
@ -10569,6 +10637,13 @@ img { max-width: 100%; display: block; }
|
||||
.dating-like-photo { font-size: 2.5rem; margin-bottom: 8px; }
|
||||
|
||||
.chat-avatar { font-size: 1.75rem; }
|
||||
.chat-avatar-img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-unread {
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
|
||||
@ -43,6 +43,7 @@ export default function ProfilePage() {
|
||||
const [meta, setMeta] = useState<PlanMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [vip, setVip] = useState(false);
|
||||
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@ -79,9 +80,11 @@ export default function ProfilePage() {
|
||||
Promise.all([
|
||||
api.getProfileStats(token),
|
||||
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);
|
||||
setFavoriteDests(f);
|
||||
setVip(Boolean(v.vip));
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
|
||||
refreshPlan();
|
||||
@ -141,7 +144,10 @@ export default function ProfilePage() {
|
||||
<div className="profile-info">
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
<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" }}>
|
||||
{planSyncStatus === "syncing" && "计划同步中…"}
|
||||
{planSyncStatus === "synced" && "✓ 计划已云端同步"}
|
||||
|
||||
@ -6,15 +6,37 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
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() {
|
||||
const { user, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [items, setItems] = useState<ConversationItem[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.getConversations(token).then(setItems).catch(() => setItems([]));
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api
|
||||
.getConversations(token)
|
||||
.then(setItems)
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
if (!user) {
|
||||
@ -41,9 +63,15 @@ export default function ChatClient() {
|
||||
<h1>{t.chat.title}</h1>
|
||||
</div>
|
||||
<div className="chat-list">
|
||||
{items.map((c) => (
|
||||
<Link key={c.id} href={`/chat/${c.id}`} className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}>
|
||||
<span className="chat-avatar">{c.peer?.photo || "💬"}</span>
|
||||
{loading && <p className="dest-empty">加载对话…</p>}
|
||||
{!loading &&
|
||||
items.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/chat/${c.id}`}
|
||||
className={`chat-list-item reveal${c.unreadCount > 0 ? " unread" : ""}`}
|
||||
>
|
||||
<Avatar photo={c.peer?.photo} name={c.peer?.name} />
|
||||
<div>
|
||||
<strong>{c.peer?.name || "游民"}</strong>
|
||||
<p>{c.lastMessagePreview || t.chat.noMessages}</p>
|
||||
@ -51,8 +79,16 @@ export default function ChatClient() {
|
||||
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
|
||||
</Link>
|
||||
))}
|
||||
{items.length === 0 && <p className="dest-empty">{t.chat.empty}</p>}
|
||||
{!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>
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -123,7 +123,12 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<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} />
|
||||
|
||||
@ -144,6 +144,12 @@ export default function DatingClient() {
|
||||
{quota && (
|
||||
<p className="dating-quota reveal">
|
||||
{quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
|
||||
{!quota.vip && quota.remaining <= 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/join">开通 VIP 无限匹配</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -154,6 +160,8 @@ export default function DatingClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && joined && <p className="dest-empty reveal">加载候选人…</p>}
|
||||
|
||||
{top && (
|
||||
<div className="dating-card reveal">
|
||||
<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>)}
|
||||
</div>
|
||||
<div className="dating-actions">
|
||||
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")}>✕</button>
|
||||
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")}>⭐</button>
|
||||
<button type="button" className="dating-btn like" onClick={() => swipe("like")}>♥</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")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>⭐</button>
|
||||
<button type="button" className="dating-btn like" onClick={() => swipe("like")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>♥</button>
|
||||
{token && (
|
||||
<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 && (
|
||||
<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} />
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useToast } from "@/lib/toast";
|
||||
@ -12,23 +13,33 @@ import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
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) => {
|
||||
if (!token) {
|
||||
toast(t.gigs.loginFirst, "info");
|
||||
router.push("/login?next=/gigs");
|
||||
return;
|
||||
}
|
||||
const message = msg[id]?.trim();
|
||||
if (!message) return;
|
||||
if (!message) {
|
||||
toast("请先填写申请说明", "info");
|
||||
return;
|
||||
}
|
||||
setBusyId(id);
|
||||
try {
|
||||
await api.applyGig(token, id, message);
|
||||
setApplied((a) => ({ ...a, [id]: true }));
|
||||
toast(t.gigs.applyOk, "success");
|
||||
} catch {
|
||||
toast(t.gigs.applyFail, "error");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -42,17 +53,46 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
<p>{t.gigs.subtitle}</p>
|
||||
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
|
||||
</div>
|
||||
{gigs.length === 0 ? (
|
||||
<div className="gigs-empty reveal">
|
||||
<p className="dest-empty">暂时没有开放任务</p>
|
||||
<p className="section-desc">你可以发布一条赏金,或先去学院看看远程岗位</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
|
||||
<Link href="/digital" className="btn btn-sm">{t.nav.digital}</Link>
|
||||
</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>
|
||||
<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" onClick={() => apply(g.id)}>{t.gigs.apply}</button>
|
||||
{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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function JoinClient() {
|
||||
const { user, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [city, setCity] = useState("清迈");
|
||||
const [bio, setBio] = useState("");
|
||||
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 () => {
|
||||
if (!token) {
|
||||
@ -38,14 +57,28 @@ export default function JoinClient() {
|
||||
router.push("/login?next=/join");
|
||||
return;
|
||||
}
|
||||
if (vip) {
|
||||
router.push("/dating");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join");
|
||||
localStorage.setItem("nomadro-pay-order", res.order_id);
|
||||
window.location.href = res.redirect_url;
|
||||
} catch {
|
||||
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;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : t.join.payFail, "error");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -57,6 +90,9 @@ export default function JoinClient() {
|
||||
<span className="section-tag">{t.join.tag}</span>
|
||||
<h1>{t.join.title}</h1>
|
||||
<p>{t.join.subtitle}</p>
|
||||
{!checkingVip && vip && (
|
||||
<p className="join-vip-badge" role="status">✨ 你已是 VIP · 权益已生效</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="join-grid reveal">
|
||||
@ -79,12 +115,14 @@ export default function JoinClient() {
|
||||
<li>🎙️ {t.join.perk3}</li>
|
||||
<li>📚 {t.join.perk4}</li>
|
||||
</ul>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={pay}>
|
||||
{t.join.payBtn}
|
||||
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={pay}>
|
||||
{vip ? t.join.goDating : t.join.payBtn}
|
||||
</button>
|
||||
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,27 +11,47 @@ export default function JoinPaidClient() {
|
||||
const { token } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<"loading" | "ok" | "fail">("loading");
|
||||
const [status, setStatus] = useState<"loading" | "ok" | "fail" | "need-login">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order");
|
||||
if (!token || !orderId) {
|
||||
setStatus("fail");
|
||||
if (!token) {
|
||||
setStatus("need-login");
|
||||
return;
|
||||
}
|
||||
const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order") || "";
|
||||
let cancelled = false;
|
||||
let tries = 0;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
if (orderId) {
|
||||
await api.completePayment(token, orderId);
|
||||
if (!cancelled) {
|
||||
localStorage.removeItem("nomadro-pay-order");
|
||||
setStatus("ok");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const vip = await api.getVipStatus(token);
|
||||
if (!cancelled) setStatus(vip.vip ? "ok" : "fail");
|
||||
} catch {
|
||||
tries += 1;
|
||||
if (tries < 5) setTimeout(run, 800);
|
||||
else setStatus("fail");
|
||||
if (tries < 5) {
|
||||
setTimeout(run, 900);
|
||||
} else if (!cancelled) {
|
||||
try {
|
||||
const vip = await api.getVipStatus(token);
|
||||
setStatus(vip.vip ? "ok" : "fail");
|
||||
} catch {
|
||||
setStatus("fail");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, searchParams]);
|
||||
|
||||
return (
|
||||
@ -39,11 +59,20 @@ export default function JoinPaidClient() {
|
||||
<div className="container">
|
||||
<div className="join-paid reveal">
|
||||
{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" && (
|
||||
<>
|
||||
<h2>🎉 {t.join.paidOk}</h2>
|
||||
<div className="join-paid-actions">
|
||||
<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" && (
|
||||
|
||||
@ -27,7 +27,17 @@ export default function Navbar() {
|
||||
setUnread(0);
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
|
||||
const explore: NavItem[] = [
|
||||
@ -238,7 +248,12 @@ export default function Navbar() {
|
||||
)}
|
||||
{user ? (
|
||||
<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>
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@ -6,28 +6,55 @@ import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { NotificationItem } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function NotificationsClient() {
|
||||
const { token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.getNotifications(token).then(setItems).catch(() => setItems([]));
|
||||
if (!token) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api
|
||||
.getNotifications(token)
|
||||
.then(setItems)
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const markAll = async () => {
|
||||
if (!token) return;
|
||||
await api.markNotificationsRead(token);
|
||||
await api.markNotificationsRead(token).catch(() => null);
|
||||
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) {
|
||||
return (
|
||||
<div className="chat-page"><div className="container">
|
||||
<Link href="/login?next=/notifications" className="btn btn-primary">{t.nav.login}</Link>
|
||||
</div></div>
|
||||
<div className="chat-page">
|
||||
<div className="container">
|
||||
<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,20 +64,62 @@ export default function NotificationsClient() {
|
||||
<div className="section-header reveal">
|
||||
<h1>{t.notifications.title}</h1>
|
||||
<div className="notif-toolbar">
|
||||
<button type="button" className="btn btn-sm" onClick={markAll}>{t.notifications.markRead}</button>
|
||||
<Link href="/settings/notifications" className="btn btn-sm">{t.notifSettings.title}</Link>
|
||||
<button type="button" className="btn btn-sm" onClick={markAll} disabled={items.every((n) => n.read)}>
|
||||
{t.notifications.markRead}
|
||||
</button>
|
||||
<Link href="/settings/notifications" className="btn btn-sm">
|
||||
{t.notifSettings.title}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-list">
|
||||
{items.map((n) => (
|
||||
{loading && <p className="dest-empty">加载中…</p>}
|
||||
{!loading &&
|
||||
items.map((n) => (
|
||||
<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}>{t.common.open}</Link>}
|
||||
<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>
|
||||
))}
|
||||
{items.length === 0 && <p className="dest-empty">{t.notifications.empty}</p>}
|
||||
{!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>
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ PocketBase is expected as a host process on :8090.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@ -22,6 +23,7 @@ GIT = "https://eric:Xiao4669805@gitea.dsx2020.com/eric/nomadweb.git"
|
||||
DOMAIN = "nomadweb.nomadro.com"
|
||||
REMOTE = "/opt/nomadweb"
|
||||
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]:
|
||||
@ -53,10 +55,10 @@ MARKER="__MARKER__"
|
||||
|
||||
cd "$REMOTE"
|
||||
git remote set-url origin "$GIT"
|
||||
git fetch --depth=80 origin dev1
|
||||
# Force-align to origin/dev1 even if prior SFTP hotfixes left dirty trees
|
||||
git checkout -f -B dev1 origin/dev1
|
||||
git reset --hard origin/dev1
|
||||
git fetch --depth=80 origin __BRANCH__
|
||||
# Force-align to origin/__BRANCH__ even if prior SFTP hotfixes left dirty trees
|
||||
git checkout -f -B __BRANCH__ origin/__BRANCH__
|
||||
git reset --hard origin/__BRANCH__
|
||||
git clean -fd \
|
||||
-e deploy/nomadro-api.env \
|
||||
-e backend/app/data/user_store.json \
|
||||
@ -199,6 +201,7 @@ def main() -> int:
|
||||
.replace("__GIT__", GIT)
|
||||
.replace("__REMOTE__", REMOTE)
|
||||
.replace("__MARKER__", MARKER)
|
||||
.replace("__BRANCH__", BRANCH)
|
||||
)
|
||||
|
||||
code, out, _ = run(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user