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:
eric 2026-08-30 18:57:10 -05:00
parent 9b3f210bd2
commit 775f90dc6d
17 changed files with 598 additions and 281 deletions

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>
); );

View File

@ -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 && (

View File

@ -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} />
</> </>
); );
} }

View File

@ -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>
); );

View File

@ -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>

View File

@ -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>
); );

View File

@ -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">
<a href="#destinations" className="btn btn-primary"> {onOpenMatcher ? (
<span>{t.hero.ctaExplore}</span> <button type="button" className="btn btn-primary" onClick={onOpenMatcher}>
<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>
{onOpenMatcher && (
<button type="button" className="btn btn-ghost hero-matcher-btn" onClick={onOpenMatcher}>
<span>{t.hero.ctaMatch}</span> <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> </button>
) : (
<a href="#destinations" className="btn btn-primary">
<span>{t.hero.ctaExplore}</span>
</a>
)} )}
<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>

View File

@ -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

View File

@ -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 />}

View File

@ -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>
); );

View File

@ -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: "/plan", label: t.nav.plan, section: "plan" }, { href: "/#map", label: t.nav.map, section: "map" },
{ href: "/compare", label: t.nav.compare, section: "compare" },
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" }, { href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
...(!isHome
? [
{ href: "/plan", label: t.nav.plan, section: "plan" },
{ href: "/compare", label: t.nav.compare, section: "compare" },
]
: []),
{ 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) => (
<Link <div
key={l.section} key={g.id}
href={l.href} className={`nav-group${openGroup === g.id ? " open" : ""}${groupActive(g.items) ? " is-active" : ""}`}
data-section={l.section}
className={
l.href.startsWith("/#")
? (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} <button
</Link> 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
key={l.section}
href={l.href}
data-section={l.section}
className={itemActive(l) ? "active" : ""}
onClick={closeAll}
>
{l.label}
</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>

View 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>
);
}

View File

@ -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>
); );

View File

@ -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
View 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,
};
}