Restructure features into linked rings instead of flat dumps.
Group nav into Explore/Connect/Grow, slim home and tools surfaces, and add progressive next-step CTAs across hubs so users follow one path at a time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9b3f210bd2
commit
775f90dc6d
@ -10742,3 +10742,177 @@ img { max-width: 100%; display: block; }
|
|||||||
.live-stage.layout-split { grid-template-columns: 1fr; }
|
.live-stage.layout-split { grid-template-columns: 1fr; }
|
||||||
.live-chat { border-left: none; border-top: var(--border-glass); min-height: 320px; }
|
.live-chat { border-left: none; border-top: var(--border-glass); min-height: 320px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— Feature rings / progressive IA —— */
|
||||||
|
.nav-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-group-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.nav-group-btn:hover,
|
||||||
|
.nav-group.open .nav-group-btn,
|
||||||
|
.nav-group.is-active .nav-group-btn {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
}
|
||||||
|
.nav-group-caret {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.nav-group-panel {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(10, 14, 23, 0.95);
|
||||||
|
border: var(--border-glass);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.nav-group.open .nav-group-panel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.nav-group-panel a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-group-panel a:hover,
|
||||||
|
.nav-group-panel a.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .nav-group-panel {
|
||||||
|
background: rgba(248, 250, 252, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-next {
|
||||||
|
margin: 48px 0 24px;
|
||||||
|
padding: 28px 0 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.ring-next-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.ring-next-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--accent-3, #4ecdc4);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ring-next-head h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ring-next-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ring-next-n2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.ring-next-n3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.ring-next-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.ring-next-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(78, 205, 196, 0.35);
|
||||||
|
}
|
||||||
|
.ring-next-emoji {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ring-next-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
.ring-next-card p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.ring-next-go {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.45;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.home-ring-wrap {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.journey-rail-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.footer-grid-rings {
|
||||||
|
grid-template-columns: 1.4fr repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
.tools-featured-grid-compact {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.tools-hub-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 28px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.nav-group-panel {
|
||||||
|
position: static;
|
||||||
|
display: none;
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 0 8px 12px;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.nav-group.open .nav-group-panel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.ring-next-n2,
|
||||||
|
.ring-next-n3,
|
||||||
|
.journey-rail-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.footer-grid-rings {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.footer-grid-rings {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,28 +5,35 @@ import Link from "next/link";
|
|||||||
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return", "airport-transfer", "invoice-fx", "hydration", "sleep", "coliving-qa", "rainy-day"];
|
const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "deposit-return"];
|
||||||
|
|
||||||
|
/** Utility drawer — not a product mega-menu. */
|
||||||
export default function ToolsHubPage() {
|
export default function ToolsHubPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
const featured = useMemo(
|
const featured = useMemo(
|
||||||
() => FEATURED.map((id) => TOOL_LINKS.find((t) => t.id === id)).filter((t): t is NonNullable<typeof t> => !!t),
|
() => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((t) => t.category === filter);
|
let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((tool) => tool.category === filter);
|
||||||
if (q.trim()) {
|
if (q.trim()) {
|
||||||
const s = q.toLowerCase();
|
const s = q.toLowerCase();
|
||||||
list = list.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s));
|
list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [filter, q]);
|
}, [filter, q]);
|
||||||
|
|
||||||
|
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteShell showFooter>
|
<SiteShell showFooter>
|
||||||
<div className="tools-hub-page">
|
<div className="tools-hub-page">
|
||||||
@ -40,91 +47,14 @@ export default function ToolsHubPage() {
|
|||||||
<p>{t.tools.subtitle}</p>
|
<p>{t.tools.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tools-core-banner">
|
<RingNext title={t.ring.continue} steps={rings.afterTools} />
|
||||||
<Link href="/next-stop" className="tools-core-card">
|
|
||||||
<span>🧭</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubNextStop}</strong>
|
|
||||||
<p>{t.tools.hubNextStopDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/meetups" className="tools-core-card">
|
|
||||||
<span>🎉</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubMeetups}</strong>
|
|
||||||
<p>{t.tools.hubMeetupsDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/community" className="tools-core-card">
|
|
||||||
<span>💬</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubCommunity}</strong>
|
|
||||||
<p>{t.tools.hubCommunityDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tools-core-banner tools-core-banner-secondary">
|
|
||||||
<Link href="/dating" className="tools-core-card">
|
|
||||||
<span>💕</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubDating}</strong>
|
|
||||||
<p>{t.tools.hubDatingDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/chat" className="tools-core-card">
|
|
||||||
<span>✉️</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubChat}</strong>
|
|
||||||
<p>{t.tools.hubChatDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/digital" className="tools-core-card">
|
|
||||||
<span>🎓</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubDigital}</strong>
|
|
||||||
<p>{t.tools.hubDigitalDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/join" className="tools-core-card">
|
|
||||||
<span>✨</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.hubJoin}</strong>
|
|
||||||
<p>{t.tools.hubJoinDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tools-core-banner tools-core-banner-secondary">
|
|
||||||
<Link href="/plan" className="tools-core-card">
|
|
||||||
<span>🗓️</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.corePlan}</strong>
|
|
||||||
<p>{t.tools.corePlanDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/compare" className="tools-core-card">
|
|
||||||
<span>⚖️</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.coreCompare}</strong>
|
|
||||||
<p>{t.tools.coreCompareDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/login?next=/plan" className="tools-core-card">
|
|
||||||
<span>☁️</span>
|
|
||||||
<div>
|
|
||||||
<strong>{t.tools.coreSync}</strong>
|
|
||||||
<p>{t.tools.coreSyncDesc}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tools-featured">
|
<div className="tools-featured">
|
||||||
<div className="tools-featured-head">
|
<div className="tools-featured-head">
|
||||||
<strong>✨ 本周推荐</strong>
|
<strong>✨ {t.tools.tag}</strong>
|
||||||
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
|
<span>{featured.length} · 先用这几个</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tools-featured-grid">
|
<div className="tools-featured-grid tools-featured-grid-compact">
|
||||||
{featured.map((tool) => (
|
{featured.map((tool) => (
|
||||||
<Link key={tool.id} href={tool.href} className="tools-featured-card">
|
<Link key={tool.id} href={tool.href} className="tools-featured-card">
|
||||||
<span>{tool.emoji}</span>
|
<span>{tool.emoji}</span>
|
||||||
@ -158,7 +88,7 @@ export default function ToolsHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tools-hub-grid">
|
<div className="tools-hub-grid">
|
||||||
{filtered.map((tool) => (
|
{visible.map((tool) => (
|
||||||
<Link key={tool.id} href={tool.href} className="tools-hub-card">
|
<Link key={tool.id} href={tool.href} className="tools-hub-card">
|
||||||
<span className="tools-hub-emoji">{tool.emoji}</span>
|
<span className="tools-hub-emoji">{tool.emoji}</span>
|
||||||
<h3>{tool.title}</h3>
|
<h3>{tool.title}</h3>
|
||||||
@ -167,9 +97,14 @@ export default function ToolsHubPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{filtered.length === 0 && (
|
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
|
||||||
<p className="dest-empty">没有匹配的工具,试试其他关键词</p>
|
<div className="tools-hub-more">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
|
||||||
|
展开全部 {filtered.length} 个工具
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{filtered.length === 0 && <p className="dest-empty">没有匹配的工具,试试其他关键词</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useMemo, 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 { Discussion, DiscussionDetail } from "@/lib/types";
|
import type { Discussion, DiscussionDetail } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
|
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function CommunityHubClient({ discussions, featured }: Props) {
|
export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [category, setCategory] = useState("全部");
|
const [category, setCategory] = useState("全部");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
@ -122,6 +125,8 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
|||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="dest-empty">{t.community.empty}</p>
|
<p className="dest-empty">{t.community.empty}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RingNext steps={rings.afterCommunity} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,8 @@ 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 type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
|
import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
|
const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
|
||||||
{ key: "friends", label: "交朋友", emoji: "🤝" },
|
{ key: "friends", label: "交朋友", emoji: "🤝" },
|
||||||
@ -18,12 +20,16 @@ const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
|
|||||||
{ key: "explore", label: "探索", emoji: "🌍" },
|
{ key: "explore", label: "探索", emoji: "🌍" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PRIMARY_INTENTS = INTENTS.slice(0, 3);
|
||||||
|
|
||||||
export default function DatingClient() {
|
export default function DatingClient() {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [intent, setIntent] = useState<MatchIntent>("friends");
|
const [intent, setIntent] = useState<MatchIntent>("friends");
|
||||||
|
const [moreIntents, setMoreIntents] = useState(false);
|
||||||
const [deck, setDeck] = useState<MatchProfile[]>([]);
|
const [deck, setDeck] = useState<MatchProfile[]>([]);
|
||||||
const [quota, setQuota] = useState<MatchQuota | null>(null);
|
const [quota, setQuota] = useState<MatchQuota | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -118,7 +124,7 @@ export default function DatingClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dating-intents reveal">
|
<div className="dating-intents reveal">
|
||||||
{INTENTS.map((i) => (
|
{(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
|
||||||
<button
|
<button
|
||||||
key={i.key}
|
key={i.key}
|
||||||
type="button"
|
type="button"
|
||||||
@ -128,6 +134,11 @@ export default function DatingClient() {
|
|||||||
{i.emoji} {i.label}
|
{i.emoji} {i.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{!moreIntents && (
|
||||||
|
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
|
||||||
|
更多意图…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quota && (
|
{quota && (
|
||||||
@ -166,6 +177,8 @@ export default function DatingClient() {
|
|||||||
{!loading && joined && !top && (
|
{!loading && joined && !top && (
|
||||||
<p className="dest-empty reveal">{t.dating.empty}</p>
|
<p className="dest-empty reveal">{t.dating.empty}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RingNext steps={rings.afterDating} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{matchModal && (
|
{matchModal && (
|
||||||
|
|||||||
@ -6,9 +6,10 @@ import { useToast } from "@/lib/toast";
|
|||||||
import { loadTrip } from "@/lib/tripStorage";
|
import { loadTrip } from "@/lib/tripStorage";
|
||||||
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
|
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
|
||||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||||
import { compareUrl } from "@/lib/compareScore";
|
|
||||||
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
|
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
|
||||||
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
|
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
const TZ_LABELS: Record<string, string> = {
|
const TZ_LABELS: Record<string, string> = {
|
||||||
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
|
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
|
||||||
@ -33,6 +34,7 @@ function getScores(d: Destination) {
|
|||||||
|
|
||||||
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
|
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
const [months, setMonths] = useState(1);
|
const [months, setMonths] = useState(1);
|
||||||
const [detail, setDetail] = useState<CityDetailPayload | null>(null);
|
const [detail, setDetail] = useState<CityDetailPayload | null>(null);
|
||||||
@ -114,18 +116,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
|
||||||
{related.length > 0 && (
|
|
||||||
<Link
|
|
||||||
href={compareUrl([dest.slug, ...related.slice(0, 2).map((d) => d.slug)])}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
>
|
|
||||||
⚖️ 对比同区
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
|
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
|
||||||
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
|
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||||
<Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(visa || overBudget) && (
|
{(visa || overBudget) && (
|
||||||
@ -320,6 +312,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RingNext steps={rings.afterCity} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { DigitalCourse } from "@/lib/types";
|
import type { DigitalCourse } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
|
||||||
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
|
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -23,7 +24,9 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
|
|||||||
<Link href="/digital/course" className="digital-card">
|
<Link href="/digital/course" className="digital-card">
|
||||||
<span className="digital-card-emoji">🎓</span>
|
<span className="digital-card-emoji">🎓</span>
|
||||||
<h3>{t.digital.course}</h3>
|
<h3>{t.digital.course}</h3>
|
||||||
<p>{course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons}</p>
|
<p>
|
||||||
|
{course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons}
|
||||||
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/book" className="digital-card">
|
<Link href="/book" className="digital-card">
|
||||||
<span className="digital-card-emoji">📖</span>
|
<span className="digital-card-emoji">📖</span>
|
||||||
@ -41,6 +44,13 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
|
|||||||
<p>{t.digital.vipDesc}</p>
|
<p>{t.digital.vipDesc}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RingNext
|
||||||
|
steps={[
|
||||||
|
{ href: "/gigs", emoji: "💼", label: t.nav.gigs, desc: t.ring.communityWork },
|
||||||
|
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { loginNextFrom } from "@/lib/authNext";
|
import { loginNextFrom } from "@/lib/authNext";
|
||||||
|
|
||||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
|
||||||
|
|
||||||
function BrandLogo() {
|
function BrandLogo() {
|
||||||
return (
|
return (
|
||||||
<Link href="/" className="logo">
|
<Link href="/" className="logo">
|
||||||
@ -27,6 +25,7 @@ function BrandLogo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Footer mirrors the three rings — essentials only, not a second sitemap. */
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -35,72 +34,47 @@ export default function Footer() {
|
|||||||
return (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="footer-grid">
|
<div className="footer-grid footer-grid-rings">
|
||||||
<div className="footer-brand">
|
<div className="footer-brand">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
<p>{locale === "en" ? "Work and live freely anywhere on this planet 🌏" : "让每个人都能自由地工作和生活在这个星球上 🌏"}</p>
|
<p>
|
||||||
<div className="social-links">
|
{locale === "en"
|
||||||
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
|
? "Work and live freely anywhere on this planet 🌏"
|
||||||
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
|
: "让每个人都能自由地工作和生活在这个星球上 🌏"}
|
||||||
<a href="https://youtube.com" target="_blank" rel="noopener noreferrer" aria-label="YouTube">▶️</a>
|
</p>
|
||||||
<a href="https://discord.com" target="_blank" rel="noopener noreferrer" aria-label="Discord">💬</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>{t.footer.explore}</h4>
|
<h4>{t.footer.explore}</h4>
|
||||||
<Link href="/#destinations">{t.footer.destinations}</Link>
|
<Link href="/#destinations">{t.footer.destinations}</Link>
|
||||||
{!isHome && (
|
|
||||||
<>
|
|
||||||
<Link href="/plan">{t.footer.plan}</Link>
|
|
||||||
<Link href="/compare">{t.footer.compare}</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link href="/#visa">{t.footer.visa}</Link>
|
|
||||||
<Link href="/#blog">{t.footer.blog}</Link>
|
|
||||||
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
||||||
|
{!isHome && <Link href="/plan">{t.footer.plan}</Link>}
|
||||||
|
<Link href="/#visa">{t.footer.visa}</Link>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<h4>{t.footer.connect}</h4>
|
||||||
<Link href="/meetups">{t.footer.meetups}</Link>
|
<Link href="/meetups">{t.footer.meetups}</Link>
|
||||||
<Link href="/community">{t.footer.community}</Link>
|
<Link href="/community">{t.footer.community}</Link>
|
||||||
<Link href="/dating">{t.footer.dating}</Link>
|
<Link href="/dating">{t.footer.dating}</Link>
|
||||||
<Link href="/gigs">{t.footer.gigs}</Link>
|
<Link href="/chat">{t.footer.chat}</Link>
|
||||||
<Link href="/digital">{t.footer.digital}</Link>
|
|
||||||
<Link href="/pricing">{t.footer.pricing}</Link>
|
|
||||||
<Link href="/videos">{t.footer.videos}</Link>
|
|
||||||
<Link href="/services">{t.footer.services}</Link>
|
|
||||||
<Link href="/ai">{t.footer.ai}</Link>
|
|
||||||
<Link href="/map">{t.footer.mapPage}</Link>
|
|
||||||
<Link href="/report">{t.footer.report}</Link>
|
|
||||||
<Link href="/about">{t.footer.about}</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>{t.footer.tools}</h4>
|
<h4>{t.footer.grow}</h4>
|
||||||
|
<Link href="/digital">{t.footer.digital}</Link>
|
||||||
|
<Link href="/gigs">{t.footer.gigs}</Link>
|
||||||
<Link href="/tools">{t.footer.toolkit}</Link>
|
<Link href="/tools">{t.footer.toolkit}</Link>
|
||||||
<Link href="/book">{t.footer.ebook}</Link>
|
<Link href="/join">{t.footer.join}</Link>
|
||||||
{!isHome && (
|
|
||||||
<>
|
|
||||||
<Link href="/plan">{t.footer.planHub}</Link>
|
|
||||||
<Link href="/compare">{t.footer.compareHub}</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link href="/changelog">{t.footer.changelog}</Link>
|
|
||||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>{t.footer.account}</h4>
|
<h4>{t.footer.account}</h4>
|
||||||
<Link href={`/login?next=${encodeURIComponent(loginNext)}`}>{t.footer.login}</Link>
|
<Link href={`/login?next=${encodeURIComponent(loginNext)}`}>{t.footer.login}</Link>
|
||||||
<Link href="/profile">{t.footer.profile}</Link>
|
|
||||||
<Link href="/join">{t.footer.join}</Link>
|
|
||||||
<Link href="/chat">{t.footer.chat}</Link>
|
|
||||||
<Link href="/notifications">{t.footer.notifications}</Link>
|
|
||||||
<Link href="/feedback">{t.footer.feedback}</Link>
|
|
||||||
<Link href="/help">{t.footer.help}</Link>
|
<Link href="/help">{t.footer.help}</Link>
|
||||||
<Link href="/contact">{t.footer.contact}</Link>
|
<Link href="/about">{t.footer.about}</Link>
|
||||||
<Link href="/privacy">{t.footer.privacy}</Link>
|
<Link href="/privacy">{t.footer.privacy}</Link>
|
||||||
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-bottom">
|
<div className="footer-bottom">
|
||||||
<p>© 2026 nomadro · <a href={SITE}>{SITE.replace("https://", "")}</a></p>
|
<p>© 2026 nomadro</p>
|
||||||
<p className="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -7,11 +7,14 @@ 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 type { GigItem } from "@/lib/types";
|
import type { GigItem } from "@/lib/types";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const apply = async (id: string) => {
|
const apply = async (id: string) => {
|
||||||
@ -32,7 +35,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="gigs-page">
|
<div className="gigs-page">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<nav className="detail-nav"><Link href="/tools">← {t.nav.tools}</Link></nav>
|
<nav className="detail-nav"><Link href="/digital">← {t.nav.digital}</Link></nav>
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">{t.gigs.tag}</span>
|
<span className="section-tag">{t.gigs.tag}</span>
|
||||||
<h1>{t.gigs.title}</h1>
|
<h1>{t.gigs.title}</h1>
|
||||||
@ -50,6 +53,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<RingNext steps={rings.afterGigs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -50,28 +50,6 @@ export default function Hero({ stats, onOpenMatcher }: Props) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const counters = document.querySelectorAll(".stat-number");
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting) return;
|
|
||||||
const el = entry.target as HTMLElement;
|
|
||||||
const target = parseInt(el.dataset.target || "0", 10);
|
|
||||||
const start = performance.now();
|
|
||||||
const step = (now: number) => {
|
|
||||||
const p = Math.min((now - start) / 2000, 1);
|
|
||||||
el.textContent = String(Math.floor((1 - Math.pow(1 - p, 3)) * target));
|
|
||||||
if (p < 1) requestAnimationFrame(step);
|
|
||||||
else el.textContent = String(target);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(step);
|
|
||||||
observer.unobserve(el);
|
|
||||||
});
|
|
||||||
}, { threshold: 0.5 });
|
|
||||||
counters.forEach((c) => observer.observe(c));
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="hero" id="hero">
|
<section className="hero" id="hero">
|
||||||
<div className="hero-bg-shapes">
|
<div className="hero-bg-shapes">
|
||||||
@ -129,32 +107,19 @@ export default function Hero({ stats, onOpenMatcher }: Props) {
|
|||||||
|
|
||||||
<div className="hero-bottom reveal">
|
<div className="hero-bottom reveal">
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
|
{onOpenMatcher ? (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onOpenMatcher}>
|
||||||
|
<span>{t.hero.ctaMatch}</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<a href="#destinations" className="btn btn-primary">
|
<a href="#destinations" className="btn btn-primary">
|
||||||
<span>{t.hero.ctaExplore}</span>
|
<span>{t.hero.ctaExplore}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
|
||||||
</a>
|
</a>
|
||||||
{onOpenMatcher && (
|
|
||||||
<button type="button" className="btn btn-ghost hero-matcher-btn" onClick={onOpenMatcher}>
|
|
||||||
<span>{t.hero.ctaMatch}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<a href="#path" className="btn btn-ghost hero-path-link"><span>{t.journey.title}</span></a>
|
<a href="#destinations" className="btn btn-ghost">
|
||||||
</div>
|
<span>{t.hero.ctaExplore}</span>
|
||||||
<div className="hero-stats">
|
</a>
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number" data-target={stats.countries}>0</span>
|
|
||||||
<span className="stat-label">{t.hero.statCountries}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-divider" />
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number" data-target={stats.avg_cost}>0</span>
|
|
||||||
<span className="stat-label">{t.hero.statCost}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-divider" />
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number" data-target={stats.satisfaction}>0</span>
|
|
||||||
<span className="stat-label">{t.hero.statHappy}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,8 +16,10 @@ import Footer from "./Footer";
|
|||||||
import ScrollProgress from "./ScrollProgress";
|
import ScrollProgress from "./ScrollProgress";
|
||||||
import SectionNav from "./SectionNav";
|
import SectionNav from "./SectionNav";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
import MatcherTrigger from "./MatcherTrigger";
|
|
||||||
import WhenVisible from "./WhenVisible";
|
import WhenVisible from "./WhenVisible";
|
||||||
|
import RingNext from "./RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) =>
|
const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) =>
|
||||||
dynamic(loader, { ssr: false, loading: () => null });
|
dynamic(loader, { ssr: false, loading: () => null });
|
||||||
@ -45,8 +47,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomeClient(props: Props) {
|
export default function HomeClient(props: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [matcherOpen, setMatcherOpen] = useState(false);
|
const [matcherOpen, setMatcherOpen] = useState(false);
|
||||||
const [chromeReady, setChromeReady] = useState(false);
|
const [chromeReady, setChromeReady] = useState(false);
|
||||||
|
const rings = useRingSteps();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMatcher = () => setMatcherOpen(true);
|
const onMatcher = () => setMatcherOpen(true);
|
||||||
@ -105,6 +109,10 @@ export default function HomeClient(props: Props) {
|
|||||||
<WorldMap destinations={props.destinations} />
|
<WorldMap destinations={props.destinations} />
|
||||||
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
|
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
|
||||||
|
|
||||||
|
<div className="container home-ring-wrap">
|
||||||
|
<RingNext title={t.ring.continue} steps={rings.afterDiscover} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<WhenVisible minHeight={280}>
|
<WhenVisible minHeight={280}>
|
||||||
<VisaSection visas={props.visas} />
|
<VisaSection visas={props.visas} />
|
||||||
</WhenVisible>
|
</WhenVisible>
|
||||||
@ -118,7 +126,6 @@ export default function HomeClient(props: Props) {
|
|||||||
<Footer />
|
<Footer />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<SectionNav />
|
<SectionNav />
|
||||||
<MatcherTrigger onClick={openMatcher} />
|
|
||||||
|
|
||||||
{matcherOpen && (
|
{matcherOpen && (
|
||||||
<DestinationMatcher
|
<DestinationMatcher
|
||||||
|
|||||||
@ -7,7 +7,7 @@ interface Props {
|
|||||||
onOpenMatcher: () => void;
|
onOpenMatcher: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Homepage discovery path — no plan/compare on home */
|
/** Homepage spine: Discover → Belong → Grow — hide the rest until needed. */
|
||||||
export default function JourneyStrip({ onOpenMatcher }: Props) {
|
export default function JourneyStrip({ onOpenMatcher }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -17,33 +17,25 @@ export default function JourneyStrip({ onOpenMatcher }: Props) {
|
|||||||
emoji: "🌍",
|
emoji: "🌍",
|
||||||
title: t.journey.step1Title,
|
title: t.journey.step1Title,
|
||||||
desc: t.journey.step1Desc,
|
desc: t.journey.step1Desc,
|
||||||
href: "/#destinations",
|
onClick: onOpenMatcher,
|
||||||
cta: t.journey.step1Cta,
|
cta: t.journey.step1Cta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: "02",
|
n: "02",
|
||||||
emoji: "🎯",
|
emoji: "🤝",
|
||||||
title: t.journey.step2Title,
|
title: t.journey.step2Title,
|
||||||
desc: t.journey.step2Desc,
|
desc: t.journey.step2Desc,
|
||||||
onClick: onOpenMatcher,
|
href: "/meetups",
|
||||||
cta: t.journey.matchCta,
|
cta: t.journey.step2Cta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: "03",
|
n: "03",
|
||||||
emoji: "📋",
|
emoji: "🚀",
|
||||||
title: t.journey.step3Title,
|
title: t.journey.step3Title,
|
||||||
desc: t.journey.step3Desc,
|
desc: t.journey.step3Desc,
|
||||||
href: "/#visa",
|
href: "/digital",
|
||||||
cta: t.journey.step3Cta,
|
cta: t.journey.step3Cta,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
n: "04",
|
|
||||||
emoji: "🛠️",
|
|
||||||
title: t.journey.step4Title,
|
|
||||||
desc: t.journey.step4Desc,
|
|
||||||
href: "/tools",
|
|
||||||
cta: t.journey.step4Cta,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -54,7 +46,7 @@ export default function JourneyStrip({ onOpenMatcher }: Props) {
|
|||||||
<h2>{t.journey.title}</h2>
|
<h2>{t.journey.title}</h2>
|
||||||
<p>{t.journey.subtitle}</p>
|
<p>{t.journey.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<ol className="journey-rail reveal">
|
<ol className="journey-rail journey-rail-3 reveal">
|
||||||
{steps.map((s, i) => (
|
{steps.map((s, i) => (
|
||||||
<li key={s.n} className="journey-step">
|
<li key={s.n} className="journey-step">
|
||||||
{i > 0 && <span className="journey-connector" aria-hidden />}
|
{i > 0 && <span className="journey-connector" aria-hidden />}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { useAuth } from "@/lib/auth";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Meetup } from "@/lib/types";
|
import type { Meetup } from "@/lib/types";
|
||||||
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
||||||
|
import RingNext from "@/components/RingNext";
|
||||||
|
import { useRingSteps } from "@/lib/rings";
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
const MODE_LABELS: Record<string, string> = {
|
||||||
online: "💻 线上",
|
online: "💻 线上",
|
||||||
@ -21,6 +23,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function MeetupsClient({ meetups }: Props) {
|
export default function MeetupsClient({ meetups }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const rings = useRingSteps();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
||||||
@ -134,6 +137,7 @@ export default function MeetupsClient({ meetups }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<NewsletterSubscribe source="meetups" />
|
<NewsletterSubscribe source="meetups" />
|
||||||
|
<RingNext steps={rings.afterMeetups} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
@ -8,6 +8,8 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { loginNextFrom } from "@/lib/authNext";
|
import { loginNextFrom } from "@/lib/authNext";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
type NavItem = { href: string; label: string; section: string };
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const { t, locale, toggleLocale } = useI18n();
|
const { t, locale, toggleLocale } = useI18n();
|
||||||
@ -15,34 +17,51 @@ export default function Navbar() {
|
|||||||
const isHome = pathname === "/";
|
const isHome = pathname === "/";
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [openGroup, setOpenGroup] = useState<string | null>(null);
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState("");
|
||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) { setUnread(0); return; }
|
if (!token) {
|
||||||
|
setUnread(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
|
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
|
||||||
}, [token, pathname]);
|
}, [token, pathname]);
|
||||||
|
|
||||||
const ALL_LINKS = [
|
const explore: NavItem[] = [
|
||||||
{ href: "/#map", label: t.nav.map, section: "map" },
|
|
||||||
{ href: "/#destinations", label: t.nav.destinations, section: "destinations" },
|
{ href: "/#destinations", label: t.nav.destinations, section: "destinations" },
|
||||||
|
{ href: "/#map", label: t.nav.map, section: "map" },
|
||||||
|
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
|
||||||
|
...(!isHome
|
||||||
|
? [
|
||||||
{ href: "/plan", label: t.nav.plan, section: "plan" },
|
{ href: "/plan", label: t.nav.plan, section: "plan" },
|
||||||
{ href: "/compare", label: t.nav.compare, section: "compare" },
|
{ href: "/compare", label: t.nav.compare, section: "compare" },
|
||||||
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
|
]
|
||||||
|
: []),
|
||||||
|
{ href: "/#visa", label: t.nav.visa, section: "visa" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const connect: NavItem[] = [
|
||||||
{ href: "/meetups", label: t.nav.meetups, section: "meetups" },
|
{ href: "/meetups", label: t.nav.meetups, section: "meetups" },
|
||||||
{ 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: "/gigs", label: t.nav.gigs, section: "gigs" },
|
|
||||||
{ href: "/chat", label: t.nav.chat, section: "chat" },
|
{ href: "/chat", label: t.nav.chat, section: "chat" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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: "/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" },
|
||||||
{ href: "/#visa", label: t.nav.visa, section: "visa" },
|
|
||||||
{ href: "/#blog", label: t.nav.blog, section: "blog" },
|
|
||||||
];
|
];
|
||||||
const LINKS = isHome
|
|
||||||
? ALL_LINKS.filter((l) => l.href !== "/plan" && l.href !== "/compare")
|
const groups = [
|
||||||
: ALL_LINKS;
|
{ id: "explore", label: t.nav.exploreGroup, items: explore },
|
||||||
|
{ id: "connect", label: t.nav.connectGroup, items: connect },
|
||||||
|
{ id: "grow", label: t.nav.growGroup, items: grow },
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("nomad-theme");
|
const saved = localStorage.getItem("nomad-theme");
|
||||||
@ -62,10 +81,13 @@ export default function Navbar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isHome) return;
|
if (!isHome) return;
|
||||||
const homeLinks = LINKS.filter((l) => l.href.startsWith("/#"));
|
const homeLinks = explore.filter((l) => l.href.startsWith("/#"));
|
||||||
const sections = homeLinks.map((l) => document.getElementById(l.section)).filter(Boolean);
|
const sections = homeLinks.map((l) => document.getElementById(l.section)).filter(Boolean);
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
(entries) =>
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) setActive(e.target.id);
|
||||||
|
}),
|
||||||
{ threshold: 0.3, rootMargin: "-80px 0px -50% 0px" }
|
{ threshold: 0.3, rootMargin: "-80px 0px -50% 0px" }
|
||||||
);
|
);
|
||||||
sections.forEach((s) => observer.observe(s!));
|
sections.forEach((s) => observer.observe(s!));
|
||||||
@ -74,9 +96,24 @@ export default function Navbar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = menuOpen ? "hidden" : "";
|
document.body.style.overflow = menuOpen ? "hidden" : "";
|
||||||
return () => { document.body.style.overflow = ""; };
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!navRef.current?.contains(e.target as Node)) setOpenGroup(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenGroup(null);
|
||||||
|
setMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const next = theme === "dark" ? "light" : "dark";
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@ -87,10 +124,30 @@ export default function Navbar() {
|
|||||||
|
|
||||||
const loginNext = loginNextFrom(pathname);
|
const loginNext = loginNextFrom(pathname);
|
||||||
|
|
||||||
|
const itemActive = (l: NavItem) => {
|
||||||
|
if (l.href.startsWith("/#")) return isHome && active === l.section;
|
||||||
|
if (l.href === "/book") return pathname.startsWith("/book");
|
||||||
|
if (l.href === "/digital") return pathname.startsWith("/digital");
|
||||||
|
if (l.href === "/chat") return pathname.startsWith("/chat");
|
||||||
|
if (l.href === "/dating") return pathname.startsWith("/dating");
|
||||||
|
if (l.href === "/meetups") return pathname.startsWith("/meetups");
|
||||||
|
if (l.href === "/community") return pathname.startsWith("/community");
|
||||||
|
if (l.href === "/gigs") return pathname.startsWith("/gigs");
|
||||||
|
if (l.href === "/tools") return pathname.startsWith("/tools");
|
||||||
|
return pathname === l.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupActive = (items: NavItem[]) => items.some(itemActive);
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setOpenGroup(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="navbar" id="navbar">
|
<header className="navbar" id="navbar" ref={navRef}>
|
||||||
<div className="nav-container">
|
<div className="nav-container">
|
||||||
<Link href="/" className="logo" onClick={() => setMenuOpen(false)}>
|
<Link href="/" className="logo" onClick={closeAll}>
|
||||||
<svg className="logo-icon" viewBox="0 0 40 40" fill="none">
|
<svg className="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||||
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" strokeWidth="2.5" />
|
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" strokeWidth="2.5" />
|
||||||
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" strokeWidth="2" strokeLinecap="round" />
|
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" strokeWidth="2" strokeLinecap="round" />
|
||||||
@ -105,33 +162,56 @@ export default function Navbar() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>nomadro</span>
|
<span>nomadro</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
|
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
|
||||||
{LINKS.map((l) => (
|
{groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className={`nav-group${openGroup === g.id ? " open" : ""}${groupActive(g.items) ? " is-active" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-group-btn"
|
||||||
|
aria-expanded={openGroup === g.id}
|
||||||
|
onClick={() => setOpenGroup(openGroup === g.id ? null : g.id)}
|
||||||
|
>
|
||||||
|
{g.label}
|
||||||
|
<span className="nav-group-caret" aria-hidden>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="nav-group-panel">
|
||||||
|
{g.items.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.section}
|
key={l.section}
|
||||||
href={l.href}
|
href={l.href}
|
||||||
data-section={l.section}
|
data-section={l.section}
|
||||||
className={
|
className={itemActive(l) ? "active" : ""}
|
||||||
l.href.startsWith("/#")
|
onClick={closeAll}
|
||||||
? (isHome && active === l.section ? "active" : "")
|
|
||||||
: (pathname === l.href || (l.href === "/book" && pathname.startsWith("/book")) || (l.href === "/digital" && pathname.startsWith("/digital")) || (l.href === "/chat" && pathname.startsWith("/chat")) || (l.href === "/dating" && pathname.startsWith("/dating")) ? "active" : "")
|
|
||||||
}
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="nav-mobile-extras">
|
<div className="nav-mobile-extras">
|
||||||
<button type="button" className="nav-lang-btn" onClick={toggleLocale} aria-label={t.nav.lang}>
|
<button type="button" className="nav-lang-btn" onClick={toggleLocale} aria-label={t.nav.lang}>
|
||||||
{locale === "zh" ? t.lang.en : t.lang.zh}
|
{locale === "zh" ? t.lang.en : t.lang.zh}
|
||||||
</button>
|
</button>
|
||||||
{!user && (
|
{!user && (
|
||||||
<Link href={`/login?next=${encodeURIComponent(loginNext)}`} className="nav-login-btn" onClick={() => setMenuOpen(false)}>
|
<Link
|
||||||
|
href={`/login?next=${encodeURIComponent(loginNext)}`}
|
||||||
|
className="nav-login-btn"
|
||||||
|
onClick={closeAll}
|
||||||
|
>
|
||||||
{t.nav.login}
|
{t.nav.login}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button type="button" className="nav-lang-btn nav-lang-desktop" onClick={toggleLocale} aria-label={t.nav.lang}>
|
<button type="button" className="nav-lang-btn nav-lang-desktop" onClick={toggleLocale} aria-label={t.nav.lang}>
|
||||||
{locale === "zh" ? "EN" : "中文"}
|
{locale === "zh" ? "EN" : "中文"}
|
||||||
</button>
|
</button>
|
||||||
@ -149,7 +229,9 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link href={`/login?next=${encodeURIComponent(loginNext)}`} className="nav-login-btn nav-login-desktop">{t.nav.login}</Link>
|
<Link href={`/login?next=${encodeURIComponent(loginNext)}`} className="nav-login-btn nav-login-desktop">
|
||||||
|
{t.nav.login}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -157,7 +239,9 @@ export default function Navbar() {
|
|||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
aria-label={t.nav.menu}
|
aria-label={t.nav.menu}
|
||||||
>
|
>
|
||||||
<span /><span /><span />
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
46
frontend/src/components/RingNext.tsx
Normal file
46
frontend/src/components/RingNext.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export type RingStep = {
|
||||||
|
href: string;
|
||||||
|
emoji: string;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
steps: RingStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progressive next-step strip — one job: show the next ring, not the whole product. */
|
||||||
|
export default function RingNext({ title, steps }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (!steps.length) return null;
|
||||||
|
return (
|
||||||
|
<aside className="ring-next reveal" aria-label={title || t.ring.continue}>
|
||||||
|
<div className="ring-next-head">
|
||||||
|
<span className="ring-next-tag">{t.ring.tag}</span>
|
||||||
|
<h2>{title || t.ring.continue}</h2>
|
||||||
|
</div>
|
||||||
|
<div className={`ring-next-grid ring-next-n${Math.min(steps.length, 3)}`}>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<Link key={s.href} href={s.href} className="ring-next-card">
|
||||||
|
<span className="ring-next-emoji" aria-hidden>
|
||||||
|
{s.emoji}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{s.label}</strong>
|
||||||
|
<p>{s.desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className="ring-next-go" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,14 +4,13 @@ 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";
|
||||||
|
|
||||||
|
/** Side dots: discover spine only — path / map / cities + next ring. */
|
||||||
export default function SectionNav() {
|
export default function SectionNav() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const PRIMARY = [
|
const PRIMARY = [
|
||||||
{ id: "map", emoji: "🗺️", label: t.nav.mapShort, type: "scroll" as const },
|
{ id: "path", emoji: "✨", label: t.journey.tag },
|
||||||
{ id: "destinations", emoji: "🌍", label: t.strip.destinations, type: "scroll" as const },
|
{ id: "map", emoji: "🗺️", label: t.nav.mapShort },
|
||||||
{ id: "path", emoji: "✨", label: t.journey.tag, type: "scroll" as const },
|
{ id: "destinations", emoji: "🌍", label: t.strip.destinations },
|
||||||
{ id: "visa", emoji: "📋", label: t.strip.visa, type: "scroll" as const },
|
|
||||||
{ id: "blog", emoji: "📝", label: t.nav.blogShort, type: "scroll" as const },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState("");
|
||||||
@ -25,14 +24,17 @@ export default function SectionNav() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollIds = PRIMARY.map((s) => s.id);
|
const sections = PRIMARY.map((s) => document.getElementById(s.id)).filter(Boolean);
|
||||||
const sections = scrollIds.map((id) => document.getElementById(id)).filter(Boolean);
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
(entries) =>
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) setActive(e.target.id);
|
||||||
|
}),
|
||||||
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
|
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
|
||||||
);
|
);
|
||||||
sections.forEach((s) => observer.observe(s!));
|
sections.forEach((s) => observer.observe(s!));
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -50,9 +52,9 @@ export default function SectionNav() {
|
|||||||
<span className="section-nav-label">{s.label}</span>
|
<span className="section-nav-label">{s.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Link href="/tools" className="section-nav-dot section-nav-tools" title={t.strip.tools} aria-label={t.strip.tools}>
|
<Link href="/meetups" className="section-nav-dot section-nav-tools" title={t.nav.meetups} aria-label={t.nav.meetups}>
|
||||||
<span className="section-nav-emoji">🛠️</span>
|
<span className="section-nav-emoji">🎉</span>
|
||||||
<span className="section-nav-label">{t.strip.tools}</span>
|
<span className="section-nav-label">{t.nav.meetups}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,15 +26,18 @@ export const zh = {
|
|||||||
theme: "切换主题",
|
theme: "切换主题",
|
||||||
menu: "菜单",
|
menu: "菜单",
|
||||||
lang: "语言",
|
lang: "语言",
|
||||||
|
exploreGroup: "探索",
|
||||||
|
connectGroup: "连接",
|
||||||
|
growGroup: "成长",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
badge: "全球数字游民正在路上",
|
badge: "全球数字游民正在路上",
|
||||||
titleLine1: "用一行代码",
|
titleLine1: "用一行代码",
|
||||||
titleLine2: "环游世界",
|
titleLine2: "环游世界",
|
||||||
ctaExplore: "探索目的地",
|
ctaExplore: "从城市开始",
|
||||||
ctaPlan: "🗓️ 旅居计划",
|
ctaPlan: "🗓️ 旅居计划",
|
||||||
ctaCompare: "⚖️ 城市对比",
|
ctaCompare: "⚖️ 城市对比",
|
||||||
ctaMatch: "🎯 智能匹配",
|
ctaMatch: "🎯 智能匹配去哪",
|
||||||
scroll: "向下滚动",
|
scroll: "向下滚动",
|
||||||
statCountries: "🗺️ 可探索国家",
|
statCountries: "🗺️ 可探索国家",
|
||||||
statCost: "💰 平均月生活费 (千元)",
|
statCost: "💰 平均月生活费 (千元)",
|
||||||
@ -54,21 +57,43 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
journey: {
|
journey: {
|
||||||
tag: "PATH",
|
tag: "PATH",
|
||||||
title: "三步开启旅居探索",
|
title: "一条路径,三环相扣",
|
||||||
subtitle: "首页专注发现好城市;计划与对比在工具箱等独立页面使用。",
|
subtitle: "先选城市,再遇见人,最后成长赚钱——按需进入,不必一次看完。",
|
||||||
step1Title: "探索城市",
|
step1Title: "发现去哪",
|
||||||
step1Desc: "地图点选或筛选目的地,收藏感兴趣的城市。",
|
step1Desc: "匹配偏好或点选地图,锁定下一座城市。",
|
||||||
step1Cta: "去看目的地",
|
step1Cta: "开始发现",
|
||||||
step2Title: "智能匹配",
|
step2Title: "遇见同行",
|
||||||
step2Desc: "回答几个偏好问题,获得适合你的推荐城市。",
|
step2Desc: "同城活动与社区讨论,先认识真实的人。",
|
||||||
|
step2Cta: "去活动",
|
||||||
|
step3Title: "成长变现",
|
||||||
|
step3Desc: "学院、赏金与小工具,在路上继续做事。",
|
||||||
|
step3Cta: "打开学院",
|
||||||
matchCta: "开始匹配",
|
matchCta: "开始匹配",
|
||||||
step3Title: "签证与准备",
|
|
||||||
step3Desc: "查看热门游民签证政策,了解入境与停留要求。",
|
|
||||||
step3Cta: "查看签证指南",
|
|
||||||
step4Title: "工具箱",
|
step4Title: "工具箱",
|
||||||
step4Desc: "费用、时差、打包、防坑……几十个小工具按需取用。",
|
step4Desc: "费用、时差、打包、防坑……几十个小工具按需取用。",
|
||||||
step4Cta: "打开工具箱",
|
step4Cta: "打开工具箱",
|
||||||
},
|
},
|
||||||
|
ring: {
|
||||||
|
tag: "NEXT",
|
||||||
|
continue: "接下来你可以",
|
||||||
|
plan: "写入旅居计划",
|
||||||
|
planDesc: "把这座城市排进时间线与预算。",
|
||||||
|
meetups: "同城活动",
|
||||||
|
meetupsDesc: "先线下遇见,再深入聊天。",
|
||||||
|
meetupsFromCommunity: "从讨论走向线下见面。",
|
||||||
|
meetupsFromDating: "匹配后一起参加活动。",
|
||||||
|
community: "社区讨论",
|
||||||
|
communityDesc: "签证、住宿、远程工作经验。",
|
||||||
|
communityWork: "远程工作话题与协作。",
|
||||||
|
dating: "游民匹配",
|
||||||
|
datingDesc: "朋友、室友或联创伙伴。",
|
||||||
|
chat: "打开私信",
|
||||||
|
chatDesc: "继续刚才的对话。",
|
||||||
|
nextStop: "下一站决策",
|
||||||
|
nextStopDesc: "不确定去哪时,用决策器收窄选择。",
|
||||||
|
digital: "游民学院",
|
||||||
|
digitalDesc: "课程与远程工作路径。",
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
mapTag: "🗺️ WORLD MAP",
|
mapTag: "🗺️ WORLD MAP",
|
||||||
mapTitle: "全球游民热力图",
|
mapTitle: "全球游民热力图",
|
||||||
@ -256,6 +281,8 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
explore: "探索",
|
explore: "探索",
|
||||||
|
connect: "连接",
|
||||||
|
grow: "成长",
|
||||||
tools: "工具",
|
tools: "工具",
|
||||||
account: "账户",
|
account: "账户",
|
||||||
destinations: "目的地",
|
destinations: "目的地",
|
||||||
@ -264,7 +291,7 @@ export const zh = {
|
|||||||
visa: "签证指南",
|
visa: "签证指南",
|
||||||
blog: "博客",
|
blog: "博客",
|
||||||
about: "关于 nomadro",
|
about: "关于 nomadro",
|
||||||
toolkit: "全部工具箱",
|
toolkit: "实用工具",
|
||||||
ebook: "游牧代码 · 电子书",
|
ebook: "游牧代码 · 电子书",
|
||||||
planHub: "计划中心",
|
planHub: "计划中心",
|
||||||
compareHub: "对比台",
|
compareHub: "对比台",
|
||||||
@ -638,15 +665,18 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
theme: "Toggle theme",
|
theme: "Toggle theme",
|
||||||
menu: "Menu",
|
menu: "Menu",
|
||||||
lang: "Language",
|
lang: "Language",
|
||||||
|
exploreGroup: "Explore",
|
||||||
|
connectGroup: "Connect",
|
||||||
|
growGroup: "Grow",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
badge: "digital nomads on the move worldwide",
|
badge: "digital nomads on the move worldwide",
|
||||||
titleLine1: "Ship code.",
|
titleLine1: "Ship code.",
|
||||||
titleLine2: "See the world",
|
titleLine2: "See the world",
|
||||||
ctaExplore: "Explore destinations",
|
ctaExplore: "Start with a city",
|
||||||
ctaPlan: "🗓️ Move plan",
|
ctaPlan: "🗓️ Move plan",
|
||||||
ctaCompare: "⚖️ Compare cities",
|
ctaCompare: "⚖️ Compare cities",
|
||||||
ctaMatch: "🎯 Smart match",
|
ctaMatch: "🎯 Match where to go",
|
||||||
scroll: "Scroll down",
|
scroll: "Scroll down",
|
||||||
statCountries: "🗺️ Countries to explore",
|
statCountries: "🗺️ Countries to explore",
|
||||||
statCost: "💰 Avg monthly cost (k CNY)",
|
statCost: "💰 Avg monthly cost (k CNY)",
|
||||||
@ -666,21 +696,43 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
},
|
},
|
||||||
journey: {
|
journey: {
|
||||||
tag: "PATH",
|
tag: "PATH",
|
||||||
title: "Start exploring in three steps",
|
title: "One path. Three linked rings.",
|
||||||
subtitle: "Home is for discovery. Plan and compare live on their own pages when you need them.",
|
subtitle: "Pick a city, meet people, then grow on the road — open only what you need next.",
|
||||||
step1Title: "Explore cities",
|
step1Title: "Find where",
|
||||||
step1Desc: "Pin the map or filter destinations and save favorites.",
|
step1Desc: "Match preferences or tap the map to lock your next city.",
|
||||||
step1Cta: "Browse cities",
|
step1Cta: "Start discovering",
|
||||||
step2Title: "Smart match",
|
step2Title: "Meet people",
|
||||||
step2Desc: "Answer a few questions and get city recommendations.",
|
step2Desc: "Local meetups and community threads before you go deep.",
|
||||||
|
step2Cta: "See events",
|
||||||
|
step3Title: "Grow & earn",
|
||||||
|
step3Desc: "Academy, gigs, and small tools — keep shipping while you travel.",
|
||||||
|
step3Cta: "Open academy",
|
||||||
matchCta: "Start matching",
|
matchCta: "Start matching",
|
||||||
step3Title: "Visas & prep",
|
|
||||||
step3Desc: "Browse popular nomad visas and stay rules.",
|
|
||||||
step3Cta: "Visa guide",
|
|
||||||
step4Title: "Toolkit",
|
step4Title: "Toolkit",
|
||||||
step4Desc: "Costs, jet lag, packing, scam tips — dozens of helpers on demand.",
|
step4Desc: "Costs, jet lag, packing, scam tips — dozens of helpers on demand.",
|
||||||
step4Cta: "Open toolkit",
|
step4Cta: "Open toolkit",
|
||||||
},
|
},
|
||||||
|
ring: {
|
||||||
|
tag: "NEXT",
|
||||||
|
continue: "What you can do next",
|
||||||
|
plan: "Add to move plan",
|
||||||
|
planDesc: "Put this city on your timeline and budget.",
|
||||||
|
meetups: "Local meetups",
|
||||||
|
meetupsDesc: "Meet offline first, then keep talking.",
|
||||||
|
meetupsFromCommunity: "Take the thread offline.",
|
||||||
|
meetupsFromDating: "Join an event together.",
|
||||||
|
community: "Community",
|
||||||
|
communityDesc: "Visas, housing, remote-work tips.",
|
||||||
|
communityWork: "Remote work threads and collab.",
|
||||||
|
dating: "Nomad match",
|
||||||
|
datingDesc: "Friends, roommates, or cofounders.",
|
||||||
|
chat: "Open messages",
|
||||||
|
chatDesc: "Continue the conversation.",
|
||||||
|
nextStop: "Next-stop picker",
|
||||||
|
nextStopDesc: "Narrow choices when you are unsure.",
|
||||||
|
digital: "Academy",
|
||||||
|
digitalDesc: "Courses and remote-work tracks.",
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
mapTag: "🗺️ WORLD MAP",
|
mapTag: "🗺️ WORLD MAP",
|
||||||
mapTitle: "Nomad heatmap",
|
mapTitle: "Nomad heatmap",
|
||||||
@ -868,6 +920,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
explore: "Explore",
|
explore: "Explore",
|
||||||
|
connect: "Connect",
|
||||||
|
grow: "Grow",
|
||||||
tools: "Tools",
|
tools: "Tools",
|
||||||
account: "Account",
|
account: "Account",
|
||||||
destinations: "Destinations",
|
destinations: "Destinations",
|
||||||
@ -876,7 +930,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
visa: "Visas",
|
visa: "Visas",
|
||||||
blog: "Blog",
|
blog: "Blog",
|
||||||
about: "About nomadro",
|
about: "About nomadro",
|
||||||
toolkit: "Toolkit",
|
toolkit: "Utility tools",
|
||||||
ebook: "Nomad Code · Ebook",
|
ebook: "Nomad Code · Ebook",
|
||||||
planHub: "Plan hub",
|
planHub: "Plan hub",
|
||||||
compareHub: "Compare hub",
|
compareHub: "Compare hub",
|
||||||
|
|||||||
54
frontend/src/lib/rings.ts
Normal file
54
frontend/src/lib/rings.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import type { RingStep } from "@/components/RingNext";
|
||||||
|
|
||||||
|
/** Shared ring definitions — hubs pick the next slice, never the full catalog. */
|
||||||
|
export function useRingSteps() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const afterCity: RingStep[] = [
|
||||||
|
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
|
||||||
|
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterMeetups: RingStep[] = [
|
||||||
|
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
|
||||||
|
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterCommunity: RingStep[] = [
|
||||||
|
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsFromCommunity },
|
||||||
|
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterDating: RingStep[] = [
|
||||||
|
{ href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc },
|
||||||
|
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsFromDating },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterDiscover: RingStep[] = [
|
||||||
|
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
|
||||||
|
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterGigs: RingStep[] = [
|
||||||
|
{ href: "/digital", emoji: "🎓", label: t.ring.digital, desc: t.ring.digitalDesc },
|
||||||
|
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityWork },
|
||||||
|
];
|
||||||
|
|
||||||
|
const afterTools: RingStep[] = [
|
||||||
|
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
|
||||||
|
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
afterCity,
|
||||||
|
afterMeetups,
|
||||||
|
afterCommunity,
|
||||||
|
afterDating,
|
||||||
|
afterDiscover,
|
||||||
|
afterGigs,
|
||||||
|
afterTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user