Harden write flows and unlock digital VIP lessons client-side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 20:31:29 -05:00
parent d43005d04a
commit d96666c329
12 changed files with 487 additions and 120 deletions

View File

@ -263,7 +263,7 @@ def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
{
"legacyId": did,
"title": payload["title"],
"excerpt": payload.get("excerpt") or payload.get("content", "")[:200],
"excerpt": (payload.get("excerpt") or payload.get("content") or "")[:1000],
"author": user_name,
"authorUserId": user_id,
"authorEmoji": payload.get("author_emoji", "🧑‍💻"),

View File

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import DigitalLessonClient from "@/components/DigitalLessonClient";
import { api } from "@/lib/api";
export const metadata: Metadata = {
title: "课程 · nomadro 学院",
@ -15,16 +14,9 @@ export default async function DigitalLessonPage({
const { m, l } = await params;
const mi = Number(m);
const li = Number(l);
let lesson = null;
let locked = false;
try {
lesson = await api.getDigitalLesson(mi, li);
} catch {
locked = true;
}
return (
<SiteShell showFooter>
<DigitalLessonClient lesson={lesson} locked={locked} moduleIndex={mi} lessonIndex={li} />
<DigitalLessonClient moduleIndex={mi} lessonIndex={li} />
</SiteShell>
);
}

View File

@ -5,24 +5,37 @@ import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { ChatMessage } from "@/lib/types";
export default function ChatThreadClient({ convId }: { convId: string }) {
const { token } = useAuth();
const { token, user } = useAuth();
const { t } = useI18n();
const { toast } = useToast();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(Boolean(token));
const bottomRef = useRef<HTMLDivElement>(null);
const load = () => {
if (!token) return;
api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
api
.getMessages(token, convId)
.then(setMessages)
.catch(() => setMessages([]))
.finally(() => setLoading(false));
};
useEffect(() => {
if (!token) {
setLoading(false);
return;
}
load();
const id = setInterval(load, 4000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]);
useEffect(() => {
@ -30,14 +43,34 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
}, [messages]);
const send = async () => {
if (!token || !text.trim()) return;
if (!token || !text.trim() || sending) return;
setSending(true);
try {
await api.sendMessage(token, convId, text.trim());
setText("");
load();
} catch { /* ignore */ }
} catch {
toast("发送失败,请稍后重试", "error");
} finally {
setSending(false);
}
};
if (!user || !token) {
return (
<div className="chat-thread-page">
<div className="container">
<div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2>
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
{t.nav.login}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="chat-thread-page">
<div className="container chat-thread-wrap">
@ -45,6 +78,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<Link href="/chat">← {t.chat.back}</Link>
</nav>
<div className="chat-thread-messages">
{loading && messages.length === 0 && <p className="dest-empty">加载消息…</p>}
{!loading && messages.length === 0 && (
<p className="dest-empty">打个招呼开始对话吧</p>
)}
{messages.map((m) => (
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
{m.body}
@ -57,9 +94,12 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t.chat.placeholder}
onKeyDown={(e) => e.key === "Enter" && send()}
onKeyDown={(e) => e.key === "Enter" && void send()}
disabled={sending}
/>
<button type="button" className="btn btn-primary" onClick={send}>{t.chat.send}</button>
<button type="button" className="btn btn-primary" disabled={sending || !text.trim()} onClick={() => void send()}>
{sending ? "…" : t.chat.send}
</button>
</div>
</div>
</div>

View File

@ -16,18 +16,35 @@ export default function CommunityNewClient() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("社区");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!token) {
router.push("/login?next=/community/new");
return;
}
const tTitle = title.trim();
const body = content.trim();
if (!tTitle || body.length < 10) {
toast("标题必填,正文至少 10 字", "info");
return;
}
setBusy(true);
try {
const res = await api.createDiscussion(token, { title, content, excerpt: content, category });
const excerpt = body.slice(0, 1000);
const res = await api.createDiscussion(token, {
title: tTitle,
content: body,
excerpt,
category,
});
toast(t.community.createOk, "success");
router.push(`/community/${res.discussion.id}`);
const id = res.discussion?.id;
router.push(id ? `/community/${id}` : "/community");
} catch {
toast(t.community.createFail, "error");
} finally {
setBusy(false);
}
};
@ -40,14 +57,18 @@ export default function CommunityNewClient() {
</div>
<div className="join-card reveal">
<label>{t.community.newSubject}</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} />
<label>{t.community.newCategory}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => <option key={c}>{c}</option>)}
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => (
<option key={c}>{c}</option>
))}
</select>
<label>{t.community.newBody}</label>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.community.newSubmit}</button>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} maxLength={4000} />
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}>
{busy ? "发布中…" : t.community.newSubmit}
</button>
</div>
</div>
</div>

View File

@ -1,11 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
const { token } = useAuth();
const [vip, setVip] = useState(false);
useEffect(() => {
if (!token) {
setVip(false);
return;
}
api.getVipStatus(token).then((s) => setVip(Boolean(s.vip))).catch(() => setVip(false));
}, [token]);
return (
<div className="digital-page">
@ -16,27 +29,45 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
<div className="section-header reveal">
<span className="section-tag">{t.digital.courseTag}</span>
<h1>{t.digital.course}</h1>
{vip ? (
<p className="join-vip-badge">✨ VIP 已解锁付费课时</p>
) : (
<p className="section-desc">
免费课时可直接学 · VIP 课时需{" "}
<Link href="/join">开通会员</Link>
</p>
)}
</div>
<div className="digital-modules reveal">
{course.modules.map((mod, mi) => (
<section key={mod.title} className="digital-module">
<h2>{mod.title}</h2>
<ul className="digital-lesson-list">
{mod.lessons.map((lesson, li) => (
<li key={lesson.title}>
<Link href={`/digital/course/${mi}/${li}`}>
<span>{lesson.title}</span>
<span className="digital-lesson-meta">
{lesson.duration}
{lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
</span>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
{course.modules.length === 0 ? (
<p className="dest-empty">课程内容准备中</p>
) : (
<div className="digital-modules reveal">
{course.modules.map((mod, mi) => (
<section key={mod.title} className="digital-module">
<h2>{mod.title}</h2>
<ul className="digital-lesson-list">
{mod.lessons.map((lesson, li) => {
const locked = !lesson.free && !vip;
return (
<li key={lesson.title}>
<Link href={`/digital/course/${mi}/${li}`}>
<span>
{locked ? "🔒 " : ""}
{lesson.title}
</span>
<span className="digital-lesson-meta">
{lesson.duration}
{lesson.free ? ` · ${t.digital.free}` : " · VIP"}
</span>
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
</div>
</div>
);

View File

@ -3,9 +3,12 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalJob } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n();
const rings = useRingSteps();
return (
<div className="digital-page">
@ -17,23 +20,36 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
<span className="section-tag">{t.digital.jobsTag}</span>
<h1>{t.digital.jobs}</h1>
<p>{t.digital.jobsSubtitle}</p>
<Link href="/gigs" className="btn btn-sm">{t.nav.gigs}</Link>
</div>
<div className="digital-jobs-grid reveal">
{jobs.map((job) => (
<article key={job.id} className="digital-job-card">
<h3>{job.title}</h3>
<p className="digital-job-company">{job.company} · {job.location}</p>
<p className="digital-job-meta">{job.type} · {job.salary}</p>
<div className="meetup-tags">
{job.tags.map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)}
</div>
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
{t.digital.apply}
</a>
</article>
))}
</div>
{jobs.length === 0 && <p className="dest-empty">{t.digital.jobsEmpty}</p>}
{jobs.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">{t.digital.jobsEmpty}</p>
<div className="dating-empty-actions">
<Link href="/gigs" className="btn btn-primary btn-sm">去赏金任务</Link>
<Link href="/community" className="btn btn-sm">社区求助</Link>
</div>
</div>
) : (
<div className="digital-jobs-grid reveal">
{jobs.map((job) => (
<article key={job.id} className="digital-job-card">
<h3>{job.title}</h3>
<p className="digital-job-company">{job.company} · {job.location}</p>
<p className="digital-job-meta">{job.type} · {job.salary}</p>
<div className="meetup-tags">
{job.tags.map((tag) => (
<span key={tag} className="meetup-tag">{tag}</span>
))}
</div>
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
{t.digital.apply}
</a>
</article>
))}
</div>
)}
<RingNext steps={rings.afterGigs} />
</div>
</div>
);

View File

@ -1,21 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalLesson } from "@/lib/types";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse, DigitalLesson } from "@/lib/types";
export default function DigitalLessonClient({
lesson,
locked,
moduleIndex,
lessonIndex,
}: {
lesson: DigitalLesson | null;
locked: boolean;
moduleIndex: number;
lessonIndex: number;
}) {
const { t } = useI18n();
const { token } = useAuth();
const [lesson, setLesson] = useState<DigitalLesson | null>(null);
const [locked, setLocked] = useState(false);
const [missing, setMissing] = useState(false);
const [loading, setLoading] = useState(true);
const [hasNext, setHasNext] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const course: DigitalCourse = await api.getDigitalCourse();
const mod = course.modules[moduleIndex];
const meta = mod?.lessons?.[lessonIndex];
if (!meta) {
if (!cancelled) {
setMissing(true);
setLocked(false);
setLesson(null);
}
return;
}
if (!cancelled) {
setHasNext(Boolean(mod.lessons[lessonIndex + 1]));
}
try {
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
if (!cancelled) {
setLesson(data);
setLocked(false);
setMissing(false);
}
} catch {
if (!cancelled) {
setLesson(null);
setLocked(!meta.free);
setMissing(meta.free);
}
}
} catch {
if (!cancelled) {
setMissing(true);
setLesson(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [moduleIndex, lessonIndex, token]);
if (loading) {
return (
<div className="digital-page">
<div className="container">
<p className="dest-empty">加载课时…</p>
</div>
</div>
);
}
if (missing) {
return (
<div className="digital-page">
<div className="container">
<div className="digital-gate reveal">
<h2>课时不存在</h2>
<Link href="/digital/course" className="btn btn-primary">{t.digital.backCourse}</Link>
</div>
</div>
</div>
);
}
if (locked || !lesson) {
return (
@ -24,8 +99,15 @@ export default function DigitalLessonClient({
<div className="digital-gate reveal">
<h2>{t.digital.locked}</h2>
<p>{t.digital.lockedDesc}</p>
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
<div className="dating-empty-actions">
{!token && (
<Link href={`/login?next=/digital/course/${moduleIndex}/${lessonIndex}`} className="btn">
{t.nav.login}
</Link>
)}
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
</div>
</div>
</div>
</div>
@ -33,7 +115,7 @@ export default function DigitalLessonClient({
}
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
const next = hasNext ? `/digital/course/${moduleIndex}/${lessonIndex + 1}` : null;
return (
<div className="digital-page">
@ -45,13 +127,25 @@ export default function DigitalLessonClient({
<span className="section-tag">{lesson.duration}</span>
<h1>{lesson.title}</h1>
<div className="digital-lesson-body">
{lesson.content.split("\n").map((p) => (
<p key={p.slice(0, 24)}>{p}</p>
{lesson.content.split("\n").filter(Boolean).map((p, i) => (
<p key={`${i}-${p.slice(0, 16)}`}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>}
<Link href={next} className="btn btn-primary">{t.digital.next} →</Link>
{prev && (
<Link href={prev} className="btn">
← {t.digital.prev}
</Link>
)}
{next ? (
<Link href={next} className="btn btn-primary">
{t.digital.next} →
</Link>
) : (
<Link href="/digital/course" className="btn btn-primary">
返回课程目录
</Link>
)}
</footer>
</article>
</div>

View File

@ -7,11 +7,20 @@ 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 DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
export default function DiscussionDetailClient({
discussionId,
initial,
}: {
discussionId: string;
initial: import("@/lib/types").DiscussionDetail;
}) {
const { user, token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const router = useRouter();
const [discussion, setDiscussion] = useState(initial);
const [reply, setReply] = useState("");
@ -25,7 +34,14 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
};
const submitReply = async () => {
if (!token || !reply.trim()) return;
if (!token) {
router.push(`/login?next=/community/${discussionId}`);
return;
}
if (!reply.trim()) {
toast("请先写一点回复内容", "info");
return;
}
setLoading(true);
try {
await api.postDiscussionReply(token, discussionId, reply.trim());
@ -47,9 +63,13 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
try {
const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
const body = discussion.excerpt || "";
return (
<div className="community-detail-page">
<div className="container">
@ -66,20 +86,33 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
</p>
</header>
<div className="community-detail-body">
<p>{discussion.excerpt}</p>
{body.split("\n").filter(Boolean).map((p, i) => (
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div>
<div className="community-detail-stats">
<span>💬 {discussion.reply_count} {t.community.replies}</span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>❤️ {likes}</button>
<span>
💬 {discussion.reply_count} {t.community.replies}
</span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>
❤️ {likes}
</button>
</div>
</article>
<section className="community-replies reveal">
<h2>{t.community.replies} ({discussion.replies.length})</h2>
<h2>
{t.community.replies} ({discussion.replies.length})
</h2>
{discussion.replies.length === 0 && (
<p className="dest-empty">还没有回复,来做第一个吧</p>
)}
{discussion.replies.map((r) => (
<div key={r.id} className="community-reply">
<div className="community-reply-head">
<span>{r.author_emoji} {r.author}</span>
<span>
{r.author_emoji} {r.author}
</span>
<time>{r.created_at}</time>
</div>
<p>{r.content}</p>
@ -89,14 +122,27 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
{user && token ? (
<div className="community-compose reveal">
<textarea value={reply} onChange={(e) => setReply(e.target.value)} rows={3} placeholder={t.community.replyPlaceholder} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>{t.community.replySend}</button>
<textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={3}
placeholder={t.community.replyPlaceholder}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>
{loading ? "发送中…" : t.community.replySend}
</button>
</div>
) : (
<div className="community-reply-hint reveal">
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">{t.nav.login}</Link>
<p>{t.community.replyHint}</p>
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">
{t.nav.login}
</Link>
</div>
)}
<RingNext steps={rings.afterCommunity} />
</div>
</div>
);

View File

@ -17,18 +17,31 @@ export default function GigPostClient() {
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!token) {
router.push("/login?next=/gigs/post");
return;
}
if (!title.trim() || !description.trim()) {
toast("请填写标题和描述", "info");
return;
}
setBusy(true);
try {
await api.createGig(token, { title, description, budget, deadline });
await api.createGig(token, {
title: title.trim(),
description: description.trim(),
budget: budget.trim() || "面议",
deadline: deadline || "",
});
toast(t.gigs.postOk, "success");
router.push("/gigs");
} catch {
toast(t.gigs.postFail, "error");
} finally {
setBusy(false);
}
};
@ -41,11 +54,13 @@ export default function GigPostClient() {
<h1>{t.gigs.postTitle}</h1>
</div>
<div className="community-compose reveal">
<input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea rows={5} placeholder={t.gigs.postDesc} value={description} onChange={(e) => setDescription(e.target.value)} />
<input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} />
<textarea rows={5} placeholder={t.gigs.postDesc} value={description} onChange={(e) => setDescription(e.target.value)} maxLength={2000} />
<input placeholder={t.gigs.postBudget} value={budget} onChange={(e) => setBudget(e.target.value)} />
<input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.gigs.postSubmit}</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}>
{busy ? "发布中…" : t.gigs.postSubmit}
</button>
</div>
</div>
</div>

View File

@ -13,6 +13,7 @@ export default function MeetupsHostClient() {
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [busy, setBusy] = useState(false);
const [form, setForm] = useState({
title: "",
city: "线上",
@ -33,12 +34,23 @@ export default function MeetupsHostClient() {
router.push("/login?next=/meetups/host");
return;
}
if (!form.title.trim() || !form.date) {
toast("请填写活动名称和日期", "info");
return;
}
setBusy(true);
try {
const res = await api.createMeetup(token, form);
await api.createMeetup(token, {
...form,
title: form.title.trim(),
description: form.description.trim(),
});
toast(t.meetups.hostOk, "success");
router.push("/meetups");
} catch {
toast(t.meetups.hostFail, "error");
} finally {
setBusy(false);
}
};
@ -52,7 +64,7 @@ export default function MeetupsHostClient() {
</div>
<div className="join-card reveal">
<label>{t.meetups.hostName}</label>
<input value={form.title} onChange={(e) => set("title", e.target.value)} />
<input value={form.title} onChange={(e) => set("title", e.target.value)} maxLength={120} />
<label>{t.meetups.hostCity}</label>
<input value={form.city} onChange={(e) => set("city", e.target.value)} />
<label>{t.meetups.hostMode}</label>
@ -66,8 +78,16 @@ export default function MeetupsHostClient() {
<input value={form.time} onChange={(e) => set("time", e.target.value)} />
<label>{t.meetups.hostVenue}</label>
<input value={form.venue} onChange={(e) => set("venue", e.target.value)} />
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={4} placeholder={t.meetups.hostDesc} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.meetups.hostSubmit}</button>
<textarea
value={form.description}
onChange={(e) => set("description", e.target.value)}
rows={4}
placeholder={t.meetups.hostDesc}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}>
{busy ? "创建中…" : t.meetups.hostSubmit}
</button>
</div>
</div>
</div>

View File

@ -2,31 +2,54 @@
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";
const PREF_KEYS = ["match", "meetup", "community", "push", "email", "marketing"] as const;
const DEFAULTS: Record<(typeof PREF_KEYS)[number], boolean> = {
match: true,
meetup: true,
community: true,
push: false,
email: true,
marketing: false,
};
export default function NotificationSettingsClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [prefs, setPrefs] = useState<Record<string, boolean>>({});
const [prefs, setPrefs] = useState<Record<string, boolean>>(DEFAULTS);
const [loading, setLoading] = useState(Boolean(token));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!token) return;
api.getNotificationPrefs(token).then(setPrefs).catch(() => {});
if (!token) {
setLoading(false);
return;
}
setLoading(true);
api
.getNotificationPrefs(token)
.then((p) => setPrefs({ ...DEFAULTS, ...p }))
.catch(() => setPrefs(DEFAULTS))
.finally(() => setLoading(false));
}, [token]);
const save = async () => {
if (!token) return;
setSaving(true);
try {
await api.setNotificationPrefs(token, prefs);
const saved = await api.setNotificationPrefs(token, prefs);
setPrefs({ ...DEFAULTS, ...saved });
toast(t.notifSettings.saved, "success");
} catch {
toast(t.notifSettings.fail, "error");
} finally {
setSaving(false);
}
};
@ -35,7 +58,10 @@ export default function NotificationSettingsClient() {
<div className="dating-page">
<div className="container">
<div className="dating-gate reveal">
<Link href="/login?next=/settings/notifications" className="btn btn-primary">{t.nav.login}</Link>
<h2>{t.notifSettings.title}</h2>
<Link href="/login?next=/settings/notifications" className="btn btn-primary">
{t.nav.login}
</Link>
</div>
</div>
</div>
@ -47,18 +73,26 @@ export default function NotificationSettingsClient() {
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/notifications">{t.notifSettings.back}</Link></nav>
<nav className="detail-nav">
<Link href="/notifications">{t.notifSettings.back}</Link>
</nav>
<div className="section-header reveal">
<h1>{t.notifSettings.title}</h1>
</div>
<div className="join-card reveal">
{(["match", "meetup", "community", "push", "email", "marketing"] as const).map((k) => (
<label key={k} className="notif-pref-row">
<span>{t.notifSettings[k]}</span>
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />
</label>
))}
<button type="button" className="btn btn-primary" onClick={save}>{t.notifSettings.save}</button>
{loading ? (
<p className="dest-empty">加载偏好…</p>
) : (
PREF_KEYS.map((k) => (
<label key={k} className="notif-pref-row">
<span>{t.notifSettings[k]}</span>
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />
</label>
))
)}
<button type="button" className="btn btn-primary" disabled={loading || saving} onClick={save}>
{saving ? "保存中…" : t.notifSettings.save}
</button>
</div>
</div>
</div>

View File

@ -6,20 +6,43 @@ import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
type LeadForm = { name: string; email: string; message: string };
export default function ServicesClient({ services }: { services: ServiceItem[] }) {
const { toast } = useToast();
const { t } = useI18n();
const [form, setForm] = useState<Record<string, { name: string; email: string; message: string }>>({});
const rings = useRingSteps();
const [form, setForm] = useState<Record<string, LeadForm>>({});
const [sent, setSent] = useState<Record<string, boolean>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const patch = (id: string, key: keyof LeadForm, value: string) => {
const cur = form[id] || { name: "", email: "", message: "" };
setForm({ ...form, [id]: { ...cur, [key]: value } });
};
const submit = async (serviceId: string) => {
const f = form[serviceId];
if (!f?.name || !f?.email || !f?.message) return;
if (!f?.name?.trim() || !f?.email?.trim() || !f?.message?.trim()) {
toast("请填写姓名、邮箱和需求说明", "info");
return;
}
setBusyId(serviceId);
try {
await api.submitServiceLead(serviceId, f);
await api.submitServiceLead(serviceId, {
name: f.name.trim(),
email: f.email.trim(),
message: f.message.trim(),
});
setSent((s) => ({ ...s, [serviceId]: true }));
toast(t.services.leadOk, "success");
} catch {
toast(t.services.leadFail, "error");
} finally {
setBusyId(null);
}
};
@ -32,19 +55,54 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
<h1>{t.services.title}</h1>
<p>{t.services.subtitle}</p>
</div>
<div className="join-grid">
{services.map((s) => (
<div key={s.id} className="join-card reveal">
<h3>{s.emoji} {s.title}</h3>
<p>{s.description}</p>
<p><strong>{s.price}</strong> · {s.provider}</p>
<input placeholder={t.services.name} value={form[s.id]?.name || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], name: e.target.value, email: form[s.id]?.email || "", message: form[s.id]?.message || "" } })} />
<input type="email" placeholder={t.services.email} value={form[s.id]?.email || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], email: e.target.value, name: form[s.id]?.name || "", message: form[s.id]?.message || "" } })} />
<textarea rows={3} placeholder={t.services.message} value={form[s.id]?.message || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], message: e.target.value, name: form[s.id]?.name || "", email: form[s.id]?.email || "" } })} />
<button type="button" className="btn btn-primary btn-sm" onClick={() => submit(s.id)}>{t.services.inquire}</button>
</div>
))}
</div>
{services.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">服务清单准备中</p>
<Link href="/community" className="btn btn-primary btn-sm">先去社区问问</Link>
</div>
) : (
<div className="join-grid">
{services.map((s) => (
<div key={s.id} className="join-card reveal">
<h3>{s.emoji} {s.title}</h3>
<p>{s.description}</p>
<p><strong>{s.price}</strong> · {s.provider}</p>
{sent[s.id] ? (
<p className="gigs-applied">✓ 已提交咨询,我们会尽快联系你</p>
) : (
<>
<input
placeholder={t.services.name}
value={form[s.id]?.name || ""}
onChange={(e) => patch(s.id, "name", e.target.value)}
/>
<input
type="email"
placeholder={t.services.email}
value={form[s.id]?.email || ""}
onChange={(e) => patch(s.id, "email", e.target.value)}
/>
<textarea
rows={3}
placeholder={t.services.message}
value={form[s.id]?.message || ""}
onChange={(e) => patch(s.id, "message", e.target.value)}
/>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyId === s.id}
onClick={() => submit(s.id)}
>
{busyId === s.id ? "提交中…" : t.services.inquire}
</button>
</>
)}
</div>
))}
</div>
)}
<RingNext steps={rings.afterGigs} />
</div>
</div>
);