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, "legacyId": did,
"title": payload["title"], "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, "author": user_name,
"authorUserId": user_id, "authorUserId": user_id,
"authorEmoji": payload.get("author_emoji", "🧑‍💻"), "authorEmoji": payload.get("author_emoji", "🧑‍💻"),

View File

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

View File

@ -5,24 +5,37 @@ import Link from "next/link";
import { api } from "@/lib/api"; 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 { useToast } from "@/lib/toast";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
export default function ChatThreadClient({ convId }: { convId: string }) { export default function ChatThreadClient({ convId }: { convId: string }) {
const { token } = useAuth(); const { token, user } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(Boolean(token));
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const load = () => { const load = () => {
if (!token) return; if (!token) return;
api.getMessages(token, convId).then(setMessages).catch(() => setMessages([])); api
.getMessages(token, convId)
.then(setMessages)
.catch(() => setMessages([]))
.finally(() => setLoading(false));
}; };
useEffect(() => { useEffect(() => {
if (!token) {
setLoading(false);
return;
}
load(); load();
const id = setInterval(load, 4000); const id = setInterval(load, 4000);
return () => clearInterval(id); return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]); }, [token, convId]);
useEffect(() => { useEffect(() => {
@ -30,14 +43,34 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
}, [messages]); }, [messages]);
const send = async () => { const send = async () => {
if (!token || !text.trim()) return; if (!token || !text.trim() || sending) return;
setSending(true);
try { try {
await api.sendMessage(token, convId, text.trim()); await api.sendMessage(token, convId, text.trim());
setText(""); setText("");
load(); 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 ( return (
<div className="chat-thread-page"> <div className="chat-thread-page">
<div className="container chat-thread-wrap"> <div className="container chat-thread-wrap">
@ -45,6 +78,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<Link href="/chat">← {t.chat.back}</Link> <Link href="/chat">← {t.chat.back}</Link>
</nav> </nav>
<div className="chat-thread-messages"> <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) => ( {messages.map((m) => (
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}> <div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
{m.body} {m.body}
@ -57,9 +94,12 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder={t.chat.placeholder} 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> </div>
</div> </div>

View File

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

View File

@ -1,11 +1,24 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types"; import type { DigitalCourse } from "@/lib/types";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) { export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n(); 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 ( return (
<div className="digital-page"> <div className="digital-page">
@ -16,27 +29,45 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.digital.courseTag}</span> <span className="section-tag">{t.digital.courseTag}</span>
<h1>{t.digital.course}</h1> <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>
<div className="digital-modules reveal"> {course.modules.length === 0 ? (
{course.modules.map((mod, mi) => ( <p className="dest-empty">课程内容准备中</p>
<section key={mod.title} className="digital-module"> ) : (
<h2>{mod.title}</h2> <div className="digital-modules reveal">
<ul className="digital-lesson-list"> {course.modules.map((mod, mi) => (
{mod.lessons.map((lesson, li) => ( <section key={mod.title} className="digital-module">
<li key={lesson.title}> <h2>{mod.title}</h2>
<Link href={`/digital/course/${mi}/${li}`}> <ul className="digital-lesson-list">
<span>{lesson.title}</span> {mod.lessons.map((lesson, li) => {
<span className="digital-lesson-meta"> const locked = !lesson.free && !vip;
{lesson.duration} return (
{lesson.free ? ` · ${t.digital.free}` : ` · VIP`} <li key={lesson.title}>
</span> <Link href={`/digital/course/${mi}/${li}`}>
</Link> <span>
</li> {locked ? "🔒 " : ""}
))} {lesson.title}
</ul> </span>
</section> <span className="digital-lesson-meta">
))} {lesson.duration}
</div> {lesson.free ? ` · ${t.digital.free}` : " · VIP"}
</span>
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

@ -1,21 +1,96 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; 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({ export default function DigitalLessonClient({
lesson,
locked,
moduleIndex, moduleIndex,
lessonIndex, lessonIndex,
}: { }: {
lesson: DigitalLesson | null;
locked: boolean;
moduleIndex: number; moduleIndex: number;
lessonIndex: number; lessonIndex: number;
}) { }) {
const { t } = useI18n(); 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) { if (locked || !lesson) {
return ( return (
@ -24,8 +99,15 @@ export default function DigitalLessonClient({
<div className="digital-gate reveal"> <div className="digital-gate reveal">
<h2>{t.digital.locked}</h2> <h2>{t.digital.locked}</h2>
<p>{t.digital.lockedDesc}</p> <p>{t.digital.lockedDesc}</p>
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link> <div className="dating-empty-actions">
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link> {!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> </div>
</div> </div>
@ -33,7 +115,7 @@ export default function DigitalLessonClient({
} }
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null; 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 ( return (
<div className="digital-page"> <div className="digital-page">
@ -45,13 +127,25 @@ export default function DigitalLessonClient({
<span className="section-tag">{lesson.duration}</span> <span className="section-tag">{lesson.duration}</span>
<h1>{lesson.title}</h1> <h1>{lesson.title}</h1>
<div className="digital-lesson-body"> <div className="digital-lesson-body">
{lesson.content.split("\n").map((p) => ( {lesson.content.split("\n").filter(Boolean).map((p, i) => (
<p key={p.slice(0, 24)}>{p}</p> <p key={`${i}-${p.slice(0, 16)}`}>{p}</p>
))} ))}
</div> </div>
<footer className="digital-lesson-nav"> <footer className="digital-lesson-nav">
{prev && <Link href={prev} className="btn">← {t.digital.prev}</Link>} {prev && (
<Link href={next} className="btn btn-primary">{t.digital.next} →</Link> <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> </footer>
</article> </article>
</div> </div>

View File

@ -7,11 +7,20 @@ 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 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 { user, token } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const router = useRouter(); const router = useRouter();
const [discussion, setDiscussion] = useState(initial); const [discussion, setDiscussion] = useState(initial);
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
@ -25,7 +34,14 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
}; };
const submitReply = async () => { 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); setLoading(true);
try { try {
await api.postDiscussionReply(token, discussionId, reply.trim()); await api.postDiscussionReply(token, discussionId, reply.trim());
@ -47,9 +63,13 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
try { try {
const res = await api.likeDiscussion(token, discussionId); const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count); setLikes(res.like_count);
} catch { /* ignore */ } } catch {
/* ignore */
}
}; };
const body = discussion.excerpt || "";
return ( return (
<div className="community-detail-page"> <div className="community-detail-page">
<div className="container"> <div className="container">
@ -66,20 +86,33 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
</p> </p>
</header> </header>
<div className="community-detail-body"> <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>
<div className="community-detail-stats"> <div className="community-detail-stats">
<span>💬 {discussion.reply_count} {t.community.replies}</span> <span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>❤️ {likes}</button> 💬 {discussion.reply_count} {t.community.replies}
</span>
<button type="button" className="btn btn-sm" onClick={toggleLike}>
❤️ {likes}
</button>
</div> </div>
</article> </article>
<section className="community-replies reveal"> <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) => ( {discussion.replies.map((r) => (
<div key={r.id} className="community-reply"> <div key={r.id} className="community-reply">
<div className="community-reply-head"> <div className="community-reply-head">
<span>{r.author_emoji} {r.author}</span> <span>
{r.author_emoji} {r.author}
</span>
<time>{r.created_at}</time> <time>{r.created_at}</time>
</div> </div>
<p>{r.content}</p> <p>{r.content}</p>
@ -89,14 +122,27 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
{user && token ? ( {user && token ? (
<div className="community-compose reveal"> <div className="community-compose reveal">
<textarea value={reply} onChange={(e) => setReply(e.target.value)} rows={3} placeholder={t.community.replyPlaceholder} /> <textarea
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}>{t.community.replySend}</button> 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>
) : ( ) : (
<div className="community-reply-hint reveal"> <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> </div>
)} )}
<RingNext steps={rings.afterCommunity} />
</div> </div>
</div> </div>
); );

View File

@ -17,18 +17,31 @@ export default function GigPostClient() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [budget, setBudget] = useState(""); const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState(""); const [deadline, setDeadline] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => { const submit = async () => {
if (!token) { if (!token) {
router.push("/login?next=/gigs/post"); router.push("/login?next=/gigs/post");
return; return;
} }
if (!title.trim() || !description.trim()) {
toast("请填写标题和描述", "info");
return;
}
setBusy(true);
try { 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"); toast(t.gigs.postOk, "success");
router.push("/gigs"); router.push("/gigs");
} catch { } catch {
toast(t.gigs.postFail, "error"); toast(t.gigs.postFail, "error");
} finally {
setBusy(false);
} }
}; };
@ -41,11 +54,13 @@ export default function GigPostClient() {
<h1>{t.gigs.postTitle}</h1> <h1>{t.gigs.postTitle}</h1>
</div> </div>
<div className="community-compose reveal"> <div className="community-compose reveal">
<input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(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)} /> <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 placeholder={t.gigs.postBudget} value={budget} onChange={(e) => setBudget(e.target.value)} />
<input type="date" value={deadline} onChange={(e) => setDeadline(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> </div>
</div> </div>

View File

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

View File

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

View File

@ -6,20 +6,43 @@ import { api } from "@/lib/api";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types"; 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[] }) { export default function ServicesClient({ services }: { services: ServiceItem[] }) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); 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 submit = async (serviceId: string) => {
const f = form[serviceId]; 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 { 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"); toast(t.services.leadOk, "success");
} catch { } catch {
toast(t.services.leadFail, "error"); toast(t.services.leadFail, "error");
} finally {
setBusyId(null);
} }
}; };
@ -32,19 +55,54 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
<h1>{t.services.title}</h1> <h1>{t.services.title}</h1>
<p>{t.services.subtitle}</p> <p>{t.services.subtitle}</p>
</div> </div>
<div className="join-grid"> {services.length === 0 ? (
{services.map((s) => ( <div className="notif-empty reveal">
<div key={s.id} className="join-card reveal"> <p className="dest-empty">服务清单准备中</p>
<h3>{s.emoji} {s.title}</h3> <Link href="/community" className="btn btn-primary btn-sm">先去社区问问</Link>
<p>{s.description}</p> </div>
<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 || "" } })} /> <div className="join-grid">
<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 || "" } })} /> {services.map((s) => (
<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 || "" } })} /> <div key={s.id} className="join-card reveal">
<button type="button" className="btn btn-primary btn-sm" onClick={() => submit(s.id)}>{t.services.inquire}</button> <h3>{s.emoji} {s.title}</h3>
</div> <p>{s.description}</p>
))} <p><strong>{s.price}</strong> · {s.provider}</p>
</div> {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>
</div> </div>
); );