Polish three rings: connect auth gates, explore shortcuts, grow nav and forms.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2ee0f3bf77
commit
ea030275ce
@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
import AiAssistantClient from "@/components/AiAssistantClient";
|
import AiAssistantClient from "@/components/AiAssistantClient";
|
||||||
|
|
||||||
@ -7,10 +8,13 @@ export const metadata: Metadata = {
|
|||||||
description: "智能推荐下一站游民城市",
|
description: "智能推荐下一站游民城市",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AiPage() {
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function AiPage() {
|
||||||
|
const destinations = await api.getDestinations().catch(() => []);
|
||||||
return (
|
return (
|
||||||
<SiteShell showFooter>
|
<SiteShell showFooter>
|
||||||
<AiAssistantClient />
|
<AiAssistantClient destinations={destinations} />
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,15 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-30",
|
||||||
|
tag: "三环登录与导航",
|
||||||
|
items: [
|
||||||
|
"Connect:个人中心三环快捷入口、匹配加载态、发起活动/发赏金登录门、私信与登录提示",
|
||||||
|
"Explore:全站快捷键、首页 hash 锚点、对比空态、AI 推荐真正写入计划",
|
||||||
|
"Grow:导航补全服务/投稿、表单校验、学院空态与 VIP 课时直达开通",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-30",
|
date: "2026-08-30",
|
||||||
tag: "三环可用深化",
|
tag: "三环可用深化",
|
||||||
|
|||||||
@ -10903,6 +10903,14 @@ img { max-width: 100%; display: block; }
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.digital-lesson-list li a.digital-lesson-locked {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-lesson-list li a.digital-lesson-locked:hover .digital-lesson-meta {
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
|
||||||
.digital-lesson-meta {
|
.digital-lesson-meta {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/login");
|
router.push("/login?next=/profile");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -291,8 +291,13 @@ export default function ProfilePage() {
|
|||||||
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
||||||
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
|
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
|
||||||
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
|
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
|
||||||
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
<Link href="/next-stop" className="quick-card">🧭 智能下一站</Link>
|
||||||
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
<Link href="/meetups" className="quick-card">🎉 游民活动</Link>
|
||||||
|
<Link href="/dating" className="quick-card">💕 游民匹配</Link>
|
||||||
|
<Link href="/chat" className="quick-card">✉️ 私信</Link>
|
||||||
|
<Link href="/notifications" className="quick-card">🔔 通知</Link>
|
||||||
|
<Link href={vip ? "/join" : "/join"} className="quick-card">{vip ? "✨ VIP 会员" : "✨ 开通 VIP"}</Link>
|
||||||
|
<Link href="/digital" className="quick-card">🎓 游民学院</Link>
|
||||||
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
|
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useToast } from "@/lib/toast";
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
import RingNext from "@/components/RingNext";
|
import RingNext from "@/components/RingNext";
|
||||||
import { useRingSteps } from "@/lib/rings";
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
@ -14,9 +17,14 @@ const SUGGESTIONS = [
|
|||||||
"清迈和巴厘岛怎么选?",
|
"清迈和巴厘岛怎么选?",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AiAssistantClient() {
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiAssistantClient({ destinations }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
const rings = useRingSteps();
|
const rings = useRingSteps();
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [reply, setReply] = useState("");
|
const [reply, setReply] = useState("");
|
||||||
@ -43,6 +51,24 @@ export default function AiAssistantClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addToPlan = () => {
|
||||||
|
const dests = cities
|
||||||
|
.map((c) => destinations.find((d) => d.slug === c.slug))
|
||||||
|
.filter((d): d is Destination => Boolean(d));
|
||||||
|
if (dests.length === 0) {
|
||||||
|
toast("推荐城市暂无完整数据,请从详情页加入计划", "info");
|
||||||
|
router.push("/plan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
|
||||||
|
if (added === 0) {
|
||||||
|
toast(skipped ? t.compare.alreadyInPlan : "未能写入计划", "info");
|
||||||
|
} else {
|
||||||
|
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`);
|
||||||
|
}
|
||||||
|
router.push("/plan");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="community-page">
|
<div className="community-page">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -93,9 +119,11 @@ export default function AiAssistantClient() {
|
|||||||
<Link href="/compare" className="btn btn-sm">
|
<Link href="/compare" className="btn btn-sm">
|
||||||
城市对比
|
城市对比
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/plan" className="btn btn-sm">
|
{cities.length > 0 && (
|
||||||
|
<button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}>
|
||||||
写入计划
|
写入计划
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export default function ChatClient() {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="dating-gate reveal">
|
<div className="dating-gate reveal">
|
||||||
<h2>{t.chat.loginTitle}</h2>
|
<h2>{t.chat.loginTitle}</h2>
|
||||||
|
<p>{t.chat.loginDesc}</p>
|
||||||
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
|
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="dating-gate reveal">
|
<div className="dating-gate reveal">
|
||||||
<h2>{t.chat.loginTitle}</h2>
|
<h2>{t.chat.loginTitle}</h2>
|
||||||
|
<p>{t.chat.loginDesc}</p>
|
||||||
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
|
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
|
||||||
{t.nav.login}
|
{t.nav.login}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -125,10 +125,24 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
|||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="notif-empty reveal">
|
<div className="notif-empty reveal">
|
||||||
<p className="dest-empty">{t.community.empty}</p>
|
<p className="dest-empty">{t.community.empty}</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
{(category !== "全部" || q.trim()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCategory("全部");
|
||||||
|
setQ("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.meetups.all}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link href="/community/new" className="btn btn-primary btn-sm">
|
<Link href="/community/new" className="btn btn-primary btn-sm">
|
||||||
{t.community.newTopic}
|
{t.community.newTopic}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RingNext steps={rings.afterCommunity} />
|
<RingNext steps={rings.afterCommunity} />
|
||||||
|
|||||||
@ -145,7 +145,11 @@ export default function CompareClient({ destinations }: Props) {
|
|||||||
<div className="plan-empty compare-page-empty">
|
<div className="plan-empty compare-page-empty">
|
||||||
<span className="plan-empty-icon">⚖️</span>
|
<span className="plan-empty-icon">⚖️</span>
|
||||||
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
|
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
|
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
|
||||||
|
<Link href="/next-stop" className="btn">{t.nav.nextStop}</Link>
|
||||||
|
<Link href="/ai" className="btn btn-ghost">{t.nav.ai}</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -8,11 +8,15 @@ 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 ContentSubmitClient() {
|
export default function ContentSubmitClient() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
@ -29,6 +33,10 @@ export default function ContentSubmitClient() {
|
|||||||
toast("请填写更完整的标题", "info");
|
toast("请填写更完整的标题", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
|
||||||
|
toast("文章/视频请填写有效链接(http/https)", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.submitContent(
|
await api.submitContent(
|
||||||
@ -113,6 +121,7 @@ export default function ContentSubmitClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<RingNext steps={rings.afterGigs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -160,7 +160,9 @@ export default function DatingClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && joined && <p className="dest-empty reveal">加载候选人…</p>}
|
{loading && (
|
||||||
|
<p className="dest-empty reveal">{joined ? "加载候选人…" : "加载匹配资料…"}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{top && (
|
{top && (
|
||||||
<div className="dating-card reveal">
|
<div className="dating-card reveal">
|
||||||
|
|||||||
@ -39,7 +39,14 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{course.modules.length === 0 ? (
|
{course.modules.length === 0 ? (
|
||||||
|
<div className="notif-empty reveal">
|
||||||
<p className="dest-empty">课程内容准备中</p>
|
<p className="dest-empty">课程内容准备中</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
<Link href="/digital" className="btn">{t.digital.back}</Link>
|
||||||
|
<Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link>
|
||||||
|
<Link href="/join" className="btn btn-primary">{t.nav.join}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="digital-modules reveal">
|
<div className="digital-modules reveal">
|
||||||
{course.modules.map((mod, mi) => (
|
{course.modules.map((mod, mi) => (
|
||||||
@ -50,16 +57,22 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
|
|||||||
const locked = !lesson.free && !vip;
|
const locked = !lesson.free && !vip;
|
||||||
return (
|
return (
|
||||||
<li key={lesson.title}>
|
<li key={lesson.title}>
|
||||||
<Link href={`/digital/course/${mi}/${li}`}>
|
{locked ? (
|
||||||
<span>
|
<Link href="/join" className="digital-lesson-locked">
|
||||||
{locked ? "🔒 " : ""}
|
<span>🔒 {lesson.title}</span>
|
||||||
{lesson.title}
|
<span className="digital-lesson-meta">
|
||||||
|
{lesson.duration} · VIP · 开通解锁
|
||||||
</span>
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href={`/digital/course/${mi}/${li}`}>
|
||||||
|
<span>{lesson.title}</span>
|
||||||
<span className="digital-lesson-meta">
|
<span className="digital-lesson-meta">
|
||||||
{lesson.duration}
|
{lesson.duration}
|
||||||
{lesson.free ? ` · ${t.digital.free}` : " · VIP"}
|
{lesson.free ? ` · ${t.digital.free}` : " · VIP"}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -6,11 +6,14 @@ 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 FeedbackClient() {
|
export default function FeedbackClient() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [category, setCategory] = useState("general");
|
const [category, setCategory] = useState("general");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -79,6 +82,7 @@ export default function FeedbackClient() {
|
|||||||
{!token && <p className="section-desc">未登录也可提交;登录后便于我们回复你</p>}
|
{!token && <p className="section-desc">未登录也可提交;登录后便于我们回复你</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<RingNext steps={rings.afterGigs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -61,6 +61,9 @@ export default function Footer() {
|
|||||||
<h4>{t.footer.grow}</h4>
|
<h4>{t.footer.grow}</h4>
|
||||||
<Link href="/digital">{t.footer.digital}</Link>
|
<Link href="/digital">{t.footer.digital}</Link>
|
||||||
<Link href="/gigs">{t.footer.gigs}</Link>
|
<Link href="/gigs">{t.footer.gigs}</Link>
|
||||||
|
<Link href="/services">{t.nav.services}</Link>
|
||||||
|
<Link href="/submit">{t.submit.tag}</Link>
|
||||||
|
<Link href="/feedback">{t.nav.feedback}</Link>
|
||||||
<Link href="/tools">{t.footer.toolkit}</Link>
|
<Link href="/tools">{t.footer.toolkit}</Link>
|
||||||
<Link href="/join">{t.footer.join}</Link>
|
<Link href="/join">{t.footer.join}</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,6 +19,21 @@ export default function GigPostClient() {
|
|||||||
const [deadline, setDeadline] = useState("");
|
const [deadline, setDeadline] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="community-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav"><Link href="/gigs">{t.gigs.back}</Link></nav>
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>{t.gigs.postTitle}</h2>
|
||||||
|
<p>{t.gigs.loginFirst}</p>
|
||||||
|
<Link href="/login?next=/gigs/post" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/login?next=/gigs/post");
|
router.push("/login?next=/gigs/post");
|
||||||
|
|||||||
@ -53,6 +53,17 @@ export default function HomeClient(props: Props) {
|
|||||||
const [chromeReady, setChromeReady] = useState(false);
|
const [chromeReady, setChromeReady] = useState(false);
|
||||||
const rings = useRingSteps();
|
const rings = useRingSteps();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (!hash) return;
|
||||||
|
const scrollToHash = () => {
|
||||||
|
const el = document.getElementById(hash);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
const t = window.setTimeout(scrollToHash, 400);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMatcher = () => setMatcherOpen(true);
|
const onMatcher = () => setMatcherOpen(true);
|
||||||
window.addEventListener("open-matcher", onMatcher);
|
window.addEventListener("open-matcher", onMatcher);
|
||||||
|
|||||||
@ -122,7 +122,17 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
|
|||||||
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
|
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
|
||||||
)}
|
)}
|
||||||
{!session.videoUrl && !session.chatUrl && (
|
{!session.videoUrl && !session.chatUrl && (
|
||||||
|
<div className="notif-empty">
|
||||||
<p className="dest-empty">直播链接尚未配置</p>
|
<p className="dest-empty">直播链接尚未配置</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
<Link href="/meetups" className="btn btn-primary">
|
||||||
|
{t.live.back}
|
||||||
|
</Link>
|
||||||
|
<Link href="/feedback" className="btn btn-ghost">
|
||||||
|
{t.nav.feedback}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -29,6 +29,21 @@ export default function MeetupsHostClient() {
|
|||||||
|
|
||||||
const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v }));
|
const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="meetups-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav"><Link href="/meetups">← {t.meetups.title}</Link></nav>
|
||||||
|
<div className="dating-gate reveal">
|
||||||
|
<h2>{t.meetups.hostTitle}</h2>
|
||||||
|
<p>{t.meetups.loginDesc}</p>
|
||||||
|
<Link href="/login?next=/meetups/host" className="btn btn-primary">{t.nav.login}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/login?next=/meetups/host");
|
router.push("/login?next=/meetups/host");
|
||||||
|
|||||||
@ -58,11 +58,15 @@ export default function Navbar() {
|
|||||||
{ href: "/community", label: t.nav.community, section: "community" },
|
{ href: "/community", label: t.nav.community, section: "community" },
|
||||||
{ href: "/dating", label: t.nav.dating, section: "dating" },
|
{ href: "/dating", label: t.nav.dating, section: "dating" },
|
||||||
{ href: "/chat", label: t.nav.chat, section: "chat" },
|
{ href: "/chat", label: t.nav.chat, section: "chat" },
|
||||||
|
{ href: "/notifications", label: t.nav.notifications, section: "notifications" },
|
||||||
|
{ href: "/join", label: t.nav.join, section: "join" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const grow: NavItem[] = [
|
const grow: NavItem[] = [
|
||||||
{ href: "/digital", label: t.nav.digital, section: "digital" },
|
{ href: "/digital", label: t.nav.digital, section: "digital" },
|
||||||
{ href: "/gigs", label: t.nav.gigs, section: "gigs" },
|
{ href: "/gigs", label: t.nav.gigs, section: "gigs" },
|
||||||
|
{ href: "/services", label: t.nav.services, section: "services" },
|
||||||
|
{ href: "/submit", label: t.submit.tag, section: "submit" },
|
||||||
{ href: "/tools", label: t.nav.tools, section: "tools" },
|
{ href: "/tools", label: t.nav.tools, section: "tools" },
|
||||||
{ href: "/book", label: t.nav.ebook, section: "ebook" },
|
{ href: "/book", label: t.nav.ebook, section: "ebook" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -30,6 +30,10 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
|
|||||||
toast("请填写姓名、邮箱和需求说明", "info");
|
toast("请填写姓名、邮箱和需求说明", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email.trim())) {
|
||||||
|
toast("请填写有效邮箱地址", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusyId(serviceId);
|
setBusyId(serviceId);
|
||||||
try {
|
try {
|
||||||
await api.submitServiceLead(serviceId, {
|
await api.submitServiceLead(serviceId, {
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Navbar from "./Navbar";
|
import Navbar from "./Navbar";
|
||||||
import ParticleCanvas from "./ParticleCanvas";
|
import ParticleCanvas from "./ParticleCanvas";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import GlobalSearch from "./GlobalSearch";
|
import GlobalSearch from "./GlobalSearch";
|
||||||
|
|
||||||
|
const KeyboardShortcuts = dynamic(() => import("./KeyboardShortcuts"), { ssr: false });
|
||||||
|
|
||||||
export default function SiteShell({ children, showFooter = false }: { children: React.ReactNode; showFooter?: boolean }) {
|
export default function SiteShell({ children, showFooter = false }: { children: React.ReactNode; showFooter?: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) =>
|
(entries) =>
|
||||||
@ -35,6 +42,15 @@ export default function SiteShell({ children, showFooter = false }: { children:
|
|||||||
<ParticleCanvas />
|
<ParticleCanvas />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
|
<KeyboardShortcuts
|
||||||
|
onOpenMatcher={() => {
|
||||||
|
if (pathname === "/") {
|
||||||
|
window.dispatchEvent(new Event("open-matcher"));
|
||||||
|
} else {
|
||||||
|
router.push("/next-stop");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
{showFooter && <Footer />}
|
{showFooter && <Footer />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -7,6 +7,14 @@ type LoginDict = {
|
|||||||
hintCompare: string;
|
hintCompare: string;
|
||||||
hintTools: string;
|
hintTools: string;
|
||||||
hintDestination: string;
|
hintDestination: string;
|
||||||
|
hintDating: string;
|
||||||
|
hintChat: string;
|
||||||
|
hintMeetups: string;
|
||||||
|
hintJoin: string;
|
||||||
|
hintNotifications: string;
|
||||||
|
hintGigs: string;
|
||||||
|
hintCommunity: string;
|
||||||
|
hintSubmit: string;
|
||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
planMergeNote: string;
|
planMergeNote: string;
|
||||||
destHome: string;
|
destHome: string;
|
||||||
@ -15,6 +23,14 @@ type LoginDict = {
|
|||||||
destCompare: string;
|
destCompare: string;
|
||||||
destTools: string;
|
destTools: string;
|
||||||
destDestination: string;
|
destDestination: string;
|
||||||
|
destDating: string;
|
||||||
|
destChat: string;
|
||||||
|
destMeetups: string;
|
||||||
|
destJoin: string;
|
||||||
|
destNotifications: string;
|
||||||
|
destGigs: string;
|
||||||
|
destCommunity: string;
|
||||||
|
destSubmit: string;
|
||||||
submitLoginDefault: string;
|
submitLoginDefault: string;
|
||||||
submitLoginPlan: string;
|
submitLoginPlan: string;
|
||||||
demoHintDefault: string;
|
demoHintDefault: string;
|
||||||
@ -55,6 +71,14 @@ function destLabel(path: string, t: LoginDict): string {
|
|||||||
if (path === "/compare") return t.destCompare;
|
if (path === "/compare") return t.destCompare;
|
||||||
if (path === "/tools") return t.destTools;
|
if (path === "/tools") return t.destTools;
|
||||||
if (path.startsWith("/destinations/")) return t.destDestination;
|
if (path.startsWith("/destinations/")) return t.destDestination;
|
||||||
|
if (path.startsWith("/dating")) return t.destDating;
|
||||||
|
if (path.startsWith("/chat")) return t.destChat;
|
||||||
|
if (path.startsWith("/meetups")) return t.destMeetups;
|
||||||
|
if (path.startsWith("/join")) return t.destJoin;
|
||||||
|
if (path.startsWith("/notifications")) return t.destNotifications;
|
||||||
|
if (path.startsWith("/gigs")) return t.destGigs;
|
||||||
|
if (path.startsWith("/community")) return t.destCommunity;
|
||||||
|
if (path === "/submit") return t.destSubmit;
|
||||||
return t.destProfile;
|
return t.destProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +88,14 @@ function hintFor(path: string, t: LoginDict): string {
|
|||||||
if (path === "/tools") return t.hintTools;
|
if (path === "/tools") return t.hintTools;
|
||||||
if (path.startsWith("/destinations/")) return t.hintDestination;
|
if (path.startsWith("/destinations/")) return t.hintDestination;
|
||||||
if (path === "/profile") return t.hintProfile;
|
if (path === "/profile") return t.hintProfile;
|
||||||
|
if (path.startsWith("/dating")) return t.hintDating;
|
||||||
|
if (path.startsWith("/chat")) return t.hintChat;
|
||||||
|
if (path.startsWith("/meetups")) return t.hintMeetups;
|
||||||
|
if (path.startsWith("/join")) return t.hintJoin;
|
||||||
|
if (path.startsWith("/notifications")) return t.hintNotifications;
|
||||||
|
if (path.startsWith("/gigs")) return t.hintGigs;
|
||||||
|
if (path.startsWith("/community")) return t.hintCommunity;
|
||||||
|
if (path === "/submit") return t.hintSubmit;
|
||||||
return t.hintExplore;
|
return t.hintExplore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -249,6 +249,14 @@ export const zh = {
|
|||||||
hintCompare: "登录后继续你的城市对比",
|
hintCompare: "登录后继续你的城市对比",
|
||||||
hintTools: "登录后使用需要账号的工具与服务",
|
hintTools: "登录后使用需要账号的工具与服务",
|
||||||
hintDestination: "登录后回到该城市页面,收藏与计划自动同步",
|
hintDestination: "登录后回到该城市页面,收藏与计划自动同步",
|
||||||
|
hintDating: "登录后开始滑动匹配,成功可私信聊天",
|
||||||
|
hintChat: "登录后查看与回复私信",
|
||||||
|
hintMeetups: "登录后报名活动或发起聚会",
|
||||||
|
hintJoin: "登录后开通或管理 VIP 会员",
|
||||||
|
hintNotifications: "登录后查看通知与提醒",
|
||||||
|
hintGigs: "登录后申请赏金或发布任务",
|
||||||
|
hintCommunity: "登录后发帖与参与讨论",
|
||||||
|
hintSubmit: "登录后提交内容投稿",
|
||||||
redirectTo: "登录后将前往",
|
redirectTo: "登录后将前往",
|
||||||
planMergeNote: "检测到本机已有行程,登录后会自动与账号合并",
|
planMergeNote: "检测到本机已有行程,登录后会自动与账号合并",
|
||||||
destHome: "首页",
|
destHome: "首页",
|
||||||
@ -257,6 +265,14 @@ export const zh = {
|
|||||||
destCompare: "城市对比",
|
destCompare: "城市对比",
|
||||||
destTools: "工具箱",
|
destTools: "工具箱",
|
||||||
destDestination: "城市详情",
|
destDestination: "城市详情",
|
||||||
|
destDating: "游民匹配",
|
||||||
|
destChat: "私信",
|
||||||
|
destMeetups: "游民活动",
|
||||||
|
destJoin: "会员中心",
|
||||||
|
destNotifications: "通知中心",
|
||||||
|
destGigs: "赏金任务",
|
||||||
|
destCommunity: "社区讨论",
|
||||||
|
destSubmit: "内容投稿",
|
||||||
tabLogin: "登录",
|
tabLogin: "登录",
|
||||||
tabRegister: "注册",
|
tabRegister: "注册",
|
||||||
nickname: "昵称",
|
nickname: "昵称",
|
||||||
@ -341,6 +357,7 @@ export const zh = {
|
|||||||
tag: "✉️ MESSAGES",
|
tag: "✉️ MESSAGES",
|
||||||
title: "私信",
|
title: "私信",
|
||||||
loginTitle: "登录后查看私信",
|
loginTitle: "登录后查看私信",
|
||||||
|
loginDesc: "匹配成功或活动结识的游民,在这里继续对话",
|
||||||
empty: "还没有对话,去匹配页滑一张卡片吧",
|
empty: "还没有对话,去匹配页滑一张卡片吧",
|
||||||
noMessages: "暂无消息",
|
noMessages: "暂无消息",
|
||||||
back: "返回列表",
|
back: "返回列表",
|
||||||
@ -476,6 +493,7 @@ export const zh = {
|
|||||||
hostSubmit: "发布活动",
|
hostSubmit: "发布活动",
|
||||||
hostOk: "活动已发布",
|
hostOk: "活动已发布",
|
||||||
hostFail: "发布失败",
|
hostFail: "发布失败",
|
||||||
|
loginDesc: "登录后即可创建线上或线下游民活动",
|
||||||
empty: "暂无匹配的活动",
|
empty: "暂无匹配的活动",
|
||||||
},
|
},
|
||||||
community: {
|
community: {
|
||||||
@ -889,6 +907,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
hintCompare: "Continue your city comparison after sign-in",
|
hintCompare: "Continue your city comparison after sign-in",
|
||||||
hintTools: "Access tools and services that need an account",
|
hintTools: "Access tools and services that need an account",
|
||||||
hintDestination: "Return to this city with favorites and plans in sync",
|
hintDestination: "Return to this city with favorites and plans in sync",
|
||||||
|
hintDating: "Sign in to swipe matches and chat on mutual like",
|
||||||
|
hintChat: "Sign in to read and reply to messages",
|
||||||
|
hintMeetups: "Sign in to RSVP or host nomad events",
|
||||||
|
hintJoin: "Sign in to manage or upgrade VIP membership",
|
||||||
|
hintNotifications: "Sign in to view alerts and reminders",
|
||||||
|
hintGigs: "Sign in to apply for gigs or post bounties",
|
||||||
|
hintCommunity: "Sign in to post and join discussions",
|
||||||
|
hintSubmit: "Sign in to submit content",
|
||||||
redirectTo: "After sign-in you'll go to",
|
redirectTo: "After sign-in you'll go to",
|
||||||
planMergeNote: "Local trip data found — we'll merge it with your account",
|
planMergeNote: "Local trip data found — we'll merge it with your account",
|
||||||
destHome: "Home",
|
destHome: "Home",
|
||||||
@ -897,6 +923,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
destCompare: "Compare",
|
destCompare: "Compare",
|
||||||
destTools: "Toolkit",
|
destTools: "Toolkit",
|
||||||
destDestination: "City page",
|
destDestination: "City page",
|
||||||
|
destDating: "Nomad match",
|
||||||
|
destChat: "Messages",
|
||||||
|
destMeetups: "Events",
|
||||||
|
destJoin: "Membership",
|
||||||
|
destNotifications: "Notifications",
|
||||||
|
destGigs: "Bounty gigs",
|
||||||
|
destCommunity: "Community",
|
||||||
|
destSubmit: "Content submit",
|
||||||
tabLogin: "Log in",
|
tabLogin: "Log in",
|
||||||
tabRegister: "Sign up",
|
tabRegister: "Sign up",
|
||||||
nickname: "Nickname",
|
nickname: "Nickname",
|
||||||
@ -981,6 +1015,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
tag: "✉️ MESSAGES",
|
tag: "✉️ MESSAGES",
|
||||||
title: "Messages",
|
title: "Messages",
|
||||||
loginTitle: "Sign in to view messages",
|
loginTitle: "Sign in to view messages",
|
||||||
|
loginDesc: "Continue conversations with nomads you matched or met at events",
|
||||||
empty: "No conversations yet — swipe on the match page",
|
empty: "No conversations yet — swipe on the match page",
|
||||||
noMessages: "No messages yet",
|
noMessages: "No messages yet",
|
||||||
back: "Back to inbox",
|
back: "Back to inbox",
|
||||||
@ -1116,6 +1151,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
hostSubmit: "Publish",
|
hostSubmit: "Publish",
|
||||||
hostOk: "Event published",
|
hostOk: "Event published",
|
||||||
hostFail: "Could not publish",
|
hostFail: "Could not publish",
|
||||||
|
loginDesc: "Sign in to host online or in-person nomad events",
|
||||||
empty: "No events match this filter",
|
empty: "No events match this filter",
|
||||||
},
|
},
|
||||||
community: {
|
community: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user