Harden write flows and unlock digital VIP lessons client-side.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d43005d04a
commit
d96666c329
@ -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", "🧑💻"),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user