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-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 { useI18n } from "@/lib/i18n";
|
||||
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() {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
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(() => {
|
||||
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()) {
|
||||
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;
|
||||
}, [filter, q]);
|
||||
|
||||
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<div className="tools-hub-page">
|
||||
@ -40,91 +47,14 @@ export default function ToolsHubPage() {
|
||||
<p>{t.tools.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="tools-core-banner">
|
||||
<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>
|
||||
<RingNext title={t.ring.continue} steps={rings.afterTools} />
|
||||
|
||||
<div className="tools-featured">
|
||||
<div className="tools-featured-head">
|
||||
<strong>✨ 本周推荐</strong>
|
||||
<span>一次上新 12+ 工具 · 周末/分账/防坑…</span>
|
||||
<strong>✨ {t.tools.tag}</strong>
|
||||
<span>{featured.length} · 先用这几个</span>
|
||||
</div>
|
||||
<div className="tools-featured-grid">
|
||||
<div className="tools-featured-grid tools-featured-grid-compact">
|
||||
{featured.map((tool) => (
|
||||
<Link key={tool.id} href={tool.href} className="tools-featured-card">
|
||||
<span>{tool.emoji}</span>
|
||||
@ -158,7 +88,7 @@ export default function ToolsHubPage() {
|
||||
</div>
|
||||
|
||||
<div className="tools-hub-grid">
|
||||
{filtered.map((tool) => (
|
||||
{visible.map((tool) => (
|
||||
<Link key={tool.id} href={tool.href} className="tools-hub-card">
|
||||
<span className="tools-hub-emoji">{tool.emoji}</span>
|
||||
<h3>{tool.title}</h3>
|
||||
@ -167,9 +97,14 @@ export default function ToolsHubPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="dest-empty">没有匹配的工具,试试其他关键词</p>
|
||||
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
|
||||
<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>
|
||||
</SiteShell>
|
||||
|
||||
@ -4,6 +4,8 @@ import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Discussion, DiscussionDetail } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
|
||||
|
||||
@ -14,6 +16,7 @@ interface Props {
|
||||
|
||||
export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [category, setCategory] = useState("全部");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
@ -122,6 +125,8 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
|
||||
{filtered.length === 0 && (
|
||||
<p className="dest-empty">{t.community.empty}</p>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterCommunity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -8,6 +8,8 @@ import { useAuth } from "@/lib/auth";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
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 }[] = [
|
||||
{ key: "friends", label: "交朋友", emoji: "🤝" },
|
||||
@ -18,12 +20,16 @@ const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
|
||||
{ key: "explore", label: "探索", emoji: "🌍" },
|
||||
];
|
||||
|
||||
const PRIMARY_INTENTS = INTENTS.slice(0, 3);
|
||||
|
||||
export default function DatingClient() {
|
||||
const { user, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [intent, setIntent] = useState<MatchIntent>("friends");
|
||||
const [moreIntents, setMoreIntents] = useState(false);
|
||||
const [deck, setDeck] = useState<MatchProfile[]>([]);
|
||||
const [quota, setQuota] = useState<MatchQuota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -118,7 +124,7 @@ export default function DatingClient() {
|
||||
</div>
|
||||
|
||||
<div className="dating-intents reveal">
|
||||
{INTENTS.map((i) => (
|
||||
{(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
|
||||
<button
|
||||
key={i.key}
|
||||
type="button"
|
||||
@ -128,6 +134,11 @@ export default function DatingClient() {
|
||||
{i.emoji} {i.label}
|
||||
</button>
|
||||
))}
|
||||
{!moreIntents && (
|
||||
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
|
||||
更多意图…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
@ -166,6 +177,8 @@ export default function DatingClient() {
|
||||
{!loading && joined && !top && (
|
||||
<p className="dest-empty reveal">{t.dating.empty}</p>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterDating} />
|
||||
</div>
|
||||
|
||||
{matchModal && (
|
||||
|
||||
@ -6,9 +6,10 @@ import { useToast } from "@/lib/toast";
|
||||
import { loadTrip } from "@/lib/tripStorage";
|
||||
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
|
||||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||||
import { compareUrl } from "@/lib/compareScore";
|
||||
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
|
||||
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> = {
|
||||
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
|
||||
@ -33,6 +34,7 @@ function getScores(d: Destination) {
|
||||
|
||||
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
|
||||
const { toast } = useToast();
|
||||
const rings = useRingSteps();
|
||||
const [added, setAdded] = useState(false);
|
||||
const [months, setMonths] = useState(1);
|
||||
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-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="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
|
||||
<Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
|
||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||
</div>
|
||||
|
||||
{(visa || overBudget) && (
|
||||
@ -320,6 +312,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RingNext steps={rings.afterCity} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { DigitalCourse } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
|
||||
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
|
||||
const { t } = useI18n();
|
||||
@ -23,7 +24,9 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
|
||||
<Link href="/digital/course" className="digital-card">
|
||||
<span className="digital-card-emoji">🎓</span>
|
||||
<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 href="/book" className="digital-card">
|
||||
<span className="digital-card-emoji">📖</span>
|
||||
@ -41,6 +44,13 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
|
||||
<p>{t.digital.vipDesc}</p>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -5,8 +5,6 @@ import { usePathname } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { loginNextFrom } from "@/lib/authNext";
|
||||
|
||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||
|
||||
function BrandLogo() {
|
||||
return (
|
||||
<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() {
|
||||
const { t, locale } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@ -35,72 +34,47 @@ export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<div className="footer-grid">
|
||||
<div className="footer-grid footer-grid-rings">
|
||||
<div className="footer-brand">
|
||||
<BrandLogo />
|
||||
<p>{locale === "en" ? "Work and live freely anywhere on this planet 🌏" : "让每个人都能自由地工作和生活在这个星球上 🌏"}</p>
|
||||
<div className="social-links">
|
||||
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
|
||||
<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>
|
||||
<a href="https://discord.com" target="_blank" rel="noopener noreferrer" aria-label="Discord">💬</a>
|
||||
</div>
|
||||
<p>
|
||||
{locale === "en"
|
||||
? "Work and live freely anywhere on this planet 🌏"
|
||||
: "让每个人都能自由地工作和生活在这个星球上 🌏"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>{t.footer.explore}</h4>
|
||||
<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>
|
||||
{!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="/community">{t.footer.community}</Link>
|
||||
<Link href="/dating">{t.footer.dating}</Link>
|
||||
<Link href="/gigs">{t.footer.gigs}</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>
|
||||
<Link href="/chat">{t.footer.chat}</Link>
|
||||
</div>
|
||||
<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="/book">{t.footer.ebook}</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>
|
||||
<Link href="/join">{t.footer.join}</Link>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>{t.footer.account}</h4>
|
||||
<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="/contact">{t.footer.contact}</Link>
|
||||
<Link href="/about">{t.footer.about}</Link>
|
||||
<Link href="/privacy">{t.footer.privacy}</Link>
|
||||
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2026 nomadro · <a href={SITE}>{SITE.replace("https://", "")}</a></p>
|
||||
<p className="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
||||
<p>© 2026 nomadro</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -7,11 +7,14 @@ import { useAuth } from "@/lib/auth";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { GigItem } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
const { token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
const apply = async (id: string) => {
|
||||
@ -32,7 +35,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
return (
|
||||
<div className="gigs-page">
|
||||
<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">
|
||||
<span className="section-tag">{t.gigs.tag}</span>
|
||||
<h1>{t.gigs.title}</h1>
|
||||
@ -50,6 +53,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<RingNext steps={rings.afterGigs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -50,28 +50,6 @@ export default function Hero({ stats, onOpenMatcher }: Props) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [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 (
|
||||
<section className="hero" id="hero">
|
||||
<div className="hero-bg-shapes">
|
||||
@ -129,32 +107,19 @@ export default function Hero({ stats, onOpenMatcher }: Props) {
|
||||
|
||||
<div className="hero-bottom reveal">
|
||||
<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">
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<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>
|
||||
<a href="#destinations" className="btn btn-ghost">
|
||||
<span>{t.hero.ctaExplore}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,8 +16,10 @@ import Footer from "./Footer";
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
import SectionNav from "./SectionNav";
|
||||
import BackToTop from "./BackToTop";
|
||||
import MatcherTrigger from "./MatcherTrigger";
|
||||
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> }>) =>
|
||||
dynamic(loader, { ssr: false, loading: () => null });
|
||||
@ -45,8 +47,10 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function HomeClient(props: Props) {
|
||||
const { t } = useI18n();
|
||||
const [matcherOpen, setMatcherOpen] = useState(false);
|
||||
const [chromeReady, setChromeReady] = useState(false);
|
||||
const rings = useRingSteps();
|
||||
|
||||
useEffect(() => {
|
||||
const onMatcher = () => setMatcherOpen(true);
|
||||
@ -105,6 +109,10 @@ export default function HomeClient(props: Props) {
|
||||
<WorldMap destinations={props.destinations} />
|
||||
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
|
||||
|
||||
<div className="container home-ring-wrap">
|
||||
<RingNext title={t.ring.continue} steps={rings.afterDiscover} />
|
||||
</div>
|
||||
|
||||
<WhenVisible minHeight={280}>
|
||||
<VisaSection visas={props.visas} />
|
||||
</WhenVisible>
|
||||
@ -118,7 +126,6 @@ export default function HomeClient(props: Props) {
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
<SectionNav />
|
||||
<MatcherTrigger onClick={openMatcher} />
|
||||
|
||||
{matcherOpen && (
|
||||
<DestinationMatcher
|
||||
|
||||
@ -7,7 +7,7 @@ interface Props {
|
||||
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) {
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -17,33 +17,25 @@ export default function JourneyStrip({ onOpenMatcher }: Props) {
|
||||
emoji: "🌍",
|
||||
title: t.journey.step1Title,
|
||||
desc: t.journey.step1Desc,
|
||||
href: "/#destinations",
|
||||
onClick: onOpenMatcher,
|
||||
cta: t.journey.step1Cta,
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
emoji: "🎯",
|
||||
emoji: "🤝",
|
||||
title: t.journey.step2Title,
|
||||
desc: t.journey.step2Desc,
|
||||
onClick: onOpenMatcher,
|
||||
cta: t.journey.matchCta,
|
||||
href: "/meetups",
|
||||
cta: t.journey.step2Cta,
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
emoji: "📋",
|
||||
emoji: "🚀",
|
||||
title: t.journey.step3Title,
|
||||
desc: t.journey.step3Desc,
|
||||
href: "/#visa",
|
||||
href: "/digital",
|
||||
cta: t.journey.step3Cta,
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
emoji: "🛠️",
|
||||
title: t.journey.step4Title,
|
||||
desc: t.journey.step4Desc,
|
||||
href: "/tools",
|
||||
cta: t.journey.step4Cta,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@ -54,7 +46,7 @@ export default function JourneyStrip({ onOpenMatcher }: Props) {
|
||||
<h2>{t.journey.title}</h2>
|
||||
<p>{t.journey.subtitle}</p>
|
||||
</div>
|
||||
<ol className="journey-rail reveal">
|
||||
<ol className="journey-rail journey-rail-3 reveal">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.n} className="journey-step">
|
||||
{i > 0 && <span className="journey-connector" aria-hidden />}
|
||||
|
||||
@ -8,6 +8,8 @@ import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Meetup } from "@/lib/types";
|
||||
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
online: "💻 线上",
|
||||
@ -21,6 +23,7 @@ interface Props {
|
||||
|
||||
export default function MeetupsClient({ meetups }: Props) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const { toast } = useToast();
|
||||
const { token } = useAuth();
|
||||
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
||||
@ -134,6 +137,7 @@ export default function MeetupsClient({ meetups }: Props) {
|
||||
)}
|
||||
|
||||
<NewsletterSubscribe source="meetups" />
|
||||
<RingNext steps={rings.afterMeetups} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
@ -8,6 +8,8 @@ import { useI18n } from "@/lib/i18n";
|
||||
import { loginNextFrom } from "@/lib/authNext";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
type NavItem = { href: string; label: string; section: string };
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, token } = useAuth();
|
||||
const { t, locale, toggleLocale } = useI18n();
|
||||
@ -15,34 +17,51 @@ export default function Navbar() {
|
||||
const isHome = pathname === "/";
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null);
|
||||
const [active, setActive] = useState("");
|
||||
const [unread, setUnread] = useState(0);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setUnread(0); return; }
|
||||
if (!token) {
|
||||
setUnread(0);
|
||||
return;
|
||||
}
|
||||
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
|
||||
}, [token, pathname]);
|
||||
|
||||
const ALL_LINKS = [
|
||||
{ href: "/#map", label: t.nav.map, section: "map" },
|
||||
const explore: NavItem[] = [
|
||||
{ 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: "/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: "/community", label: t.nav.community, section: "community" },
|
||||
{ href: "/dating", label: t.nav.dating, section: "dating" },
|
||||
{ href: "/gigs", label: t.nav.gigs, section: "gigs" },
|
||||
{ href: "/chat", label: t.nav.chat, section: "chat" },
|
||||
];
|
||||
|
||||
const grow: NavItem[] = [
|
||||
{ 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: "/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")
|
||||
: ALL_LINKS;
|
||||
|
||||
const groups = [
|
||||
{ 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(() => {
|
||||
const saved = localStorage.getItem("nomad-theme");
|
||||
@ -62,10 +81,13 @@ export default function Navbar() {
|
||||
|
||||
useEffect(() => {
|
||||
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 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" }
|
||||
);
|
||||
sections.forEach((s) => observer.observe(s!));
|
||||
@ -74,9 +96,24 @@ export default function Navbar() {
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? "hidden" : "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [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 next = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
@ -87,10 +124,30 @@ export default function Navbar() {
|
||||
|
||||
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 (
|
||||
<header className="navbar" id="navbar">
|
||||
<header className="navbar" id="navbar" ref={navRef}>
|
||||
<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">
|
||||
<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" />
|
||||
@ -105,33 +162,56 @@ export default function Navbar() {
|
||||
</svg>
|
||||
<span>nomadro</span>
|
||||
</Link>
|
||||
|
||||
<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
|
||||
key={l.section}
|
||||
href={l.href}
|
||||
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)}
|
||||
className={itemActive(l) ? "active" : ""}
|
||||
onClick={closeAll}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="nav-mobile-extras">
|
||||
<button type="button" className="nav-lang-btn" onClick={toggleLocale} aria-label={t.nav.lang}>
|
||||
{locale === "zh" ? t.lang.en : t.lang.zh}
|
||||
</button>
|
||||
{!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}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button type="button" className="nav-lang-btn nav-lang-desktop" onClick={toggleLocale} aria-label={t.nav.lang}>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
@ -149,7 +229,9 @@ export default function Navbar() {
|
||||
</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
|
||||
type="button"
|
||||
@ -157,7 +239,9 @@ export default function Navbar() {
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label={t.nav.menu}
|
||||
>
|
||||
<span /><span /><span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
</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 { useI18n } from "@/lib/i18n";
|
||||
|
||||
/** Side dots: discover spine only — path / map / cities + next ring. */
|
||||
export default function SectionNav() {
|
||||
const { t } = useI18n();
|
||||
const PRIMARY = [
|
||||
{ id: "map", emoji: "🗺️", label: t.nav.mapShort, type: "scroll" as const },
|
||||
{ id: "destinations", emoji: "🌍", label: t.strip.destinations, type: "scroll" as const },
|
||||
{ id: "path", emoji: "✨", label: t.journey.tag, type: "scroll" as const },
|
||||
{ id: "visa", emoji: "📋", label: t.strip.visa, type: "scroll" as const },
|
||||
{ id: "blog", emoji: "📝", label: t.nav.blogShort, type: "scroll" as const },
|
||||
{ id: "path", emoji: "✨", label: t.journey.tag },
|
||||
{ id: "map", emoji: "🗺️", label: t.nav.mapShort },
|
||||
{ id: "destinations", emoji: "🌍", label: t.strip.destinations },
|
||||
];
|
||||
|
||||
const [active, setActive] = useState("");
|
||||
@ -25,14 +24,17 @@ export default function SectionNav() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollIds = PRIMARY.map((s) => s.id);
|
||||
const sections = scrollIds.map((id) => document.getElementById(id)).filter(Boolean);
|
||||
const sections = PRIMARY.map((s) => document.getElementById(s.id)).filter(Boolean);
|
||||
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" }
|
||||
);
|
||||
sections.forEach((s) => observer.observe(s!));
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
@ -50,9 +52,9 @@ export default function SectionNav() {
|
||||
<span className="section-nav-label">{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<Link href="/tools" className="section-nav-dot section-nav-tools" title={t.strip.tools} aria-label={t.strip.tools}>
|
||||
<span className="section-nav-emoji">🛠️</span>
|
||||
<span className="section-nav-label">{t.strip.tools}</span>
|
||||
<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-label">{t.nav.meetups}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@ -26,15 +26,18 @@ export const zh = {
|
||||
theme: "切换主题",
|
||||
menu: "菜单",
|
||||
lang: "语言",
|
||||
exploreGroup: "探索",
|
||||
connectGroup: "连接",
|
||||
growGroup: "成长",
|
||||
},
|
||||
hero: {
|
||||
badge: "全球数字游民正在路上",
|
||||
titleLine1: "用一行代码",
|
||||
titleLine2: "环游世界",
|
||||
ctaExplore: "探索目的地",
|
||||
ctaExplore: "从城市开始",
|
||||
ctaPlan: "🗓️ 旅居计划",
|
||||
ctaCompare: "⚖️ 城市对比",
|
||||
ctaMatch: "🎯 智能匹配",
|
||||
ctaMatch: "🎯 智能匹配去哪",
|
||||
scroll: "向下滚动",
|
||||
statCountries: "🗺️ 可探索国家",
|
||||
statCost: "💰 平均月生活费 (千元)",
|
||||
@ -54,21 +57,43 @@ export const zh = {
|
||||
},
|
||||
journey: {
|
||||
tag: "PATH",
|
||||
title: "三步开启旅居探索",
|
||||
subtitle: "首页专注发现好城市;计划与对比在工具箱等独立页面使用。",
|
||||
step1Title: "探索城市",
|
||||
step1Desc: "地图点选或筛选目的地,收藏感兴趣的城市。",
|
||||
step1Cta: "去看目的地",
|
||||
step2Title: "智能匹配",
|
||||
step2Desc: "回答几个偏好问题,获得适合你的推荐城市。",
|
||||
title: "一条路径,三环相扣",
|
||||
subtitle: "先选城市,再遇见人,最后成长赚钱——按需进入,不必一次看完。",
|
||||
step1Title: "发现去哪",
|
||||
step1Desc: "匹配偏好或点选地图,锁定下一座城市。",
|
||||
step1Cta: "开始发现",
|
||||
step2Title: "遇见同行",
|
||||
step2Desc: "同城活动与社区讨论,先认识真实的人。",
|
||||
step2Cta: "去活动",
|
||||
step3Title: "成长变现",
|
||||
step3Desc: "学院、赏金与小工具,在路上继续做事。",
|
||||
step3Cta: "打开学院",
|
||||
matchCta: "开始匹配",
|
||||
step3Title: "签证与准备",
|
||||
step3Desc: "查看热门游民签证政策,了解入境与停留要求。",
|
||||
step3Cta: "查看签证指南",
|
||||
step4Title: "工具箱",
|
||||
step4Desc: "费用、时差、打包、防坑……几十个小工具按需取用。",
|
||||
step4Cta: "打开工具箱",
|
||||
},
|
||||
ring: {
|
||||
tag: "NEXT",
|
||||
continue: "接下来你可以",
|
||||
plan: "写入旅居计划",
|
||||
planDesc: "把这座城市排进时间线与预算。",
|
||||
meetups: "同城活动",
|
||||
meetupsDesc: "先线下遇见,再深入聊天。",
|
||||
meetupsFromCommunity: "从讨论走向线下见面。",
|
||||
meetupsFromDating: "匹配后一起参加活动。",
|
||||
community: "社区讨论",
|
||||
communityDesc: "签证、住宿、远程工作经验。",
|
||||
communityWork: "远程工作话题与协作。",
|
||||
dating: "游民匹配",
|
||||
datingDesc: "朋友、室友或联创伙伴。",
|
||||
chat: "打开私信",
|
||||
chatDesc: "继续刚才的对话。",
|
||||
nextStop: "下一站决策",
|
||||
nextStopDesc: "不确定去哪时,用决策器收窄选择。",
|
||||
digital: "游民学院",
|
||||
digitalDesc: "课程与远程工作路径。",
|
||||
},
|
||||
home: {
|
||||
mapTag: "🗺️ WORLD MAP",
|
||||
mapTitle: "全球游民热力图",
|
||||
@ -256,6 +281,8 @@ export const zh = {
|
||||
},
|
||||
footer: {
|
||||
explore: "探索",
|
||||
connect: "连接",
|
||||
grow: "成长",
|
||||
tools: "工具",
|
||||
account: "账户",
|
||||
destinations: "目的地",
|
||||
@ -264,7 +291,7 @@ export const zh = {
|
||||
visa: "签证指南",
|
||||
blog: "博客",
|
||||
about: "关于 nomadro",
|
||||
toolkit: "全部工具箱",
|
||||
toolkit: "实用工具",
|
||||
ebook: "游牧代码 · 电子书",
|
||||
planHub: "计划中心",
|
||||
compareHub: "对比台",
|
||||
@ -638,15 +665,18 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
theme: "Toggle theme",
|
||||
menu: "Menu",
|
||||
lang: "Language",
|
||||
exploreGroup: "Explore",
|
||||
connectGroup: "Connect",
|
||||
growGroup: "Grow",
|
||||
},
|
||||
hero: {
|
||||
badge: "digital nomads on the move worldwide",
|
||||
titleLine1: "Ship code.",
|
||||
titleLine2: "See the world",
|
||||
ctaExplore: "Explore destinations",
|
||||
ctaExplore: "Start with a city",
|
||||
ctaPlan: "🗓️ Move plan",
|
||||
ctaCompare: "⚖️ Compare cities",
|
||||
ctaMatch: "🎯 Smart match",
|
||||
ctaMatch: "🎯 Match where to go",
|
||||
scroll: "Scroll down",
|
||||
statCountries: "🗺️ Countries to explore",
|
||||
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: {
|
||||
tag: "PATH",
|
||||
title: "Start exploring in three steps",
|
||||
subtitle: "Home is for discovery. Plan and compare live on their own pages when you need them.",
|
||||
step1Title: "Explore cities",
|
||||
step1Desc: "Pin the map or filter destinations and save favorites.",
|
||||
step1Cta: "Browse cities",
|
||||
step2Title: "Smart match",
|
||||
step2Desc: "Answer a few questions and get city recommendations.",
|
||||
title: "One path. Three linked rings.",
|
||||
subtitle: "Pick a city, meet people, then grow on the road — open only what you need next.",
|
||||
step1Title: "Find where",
|
||||
step1Desc: "Match preferences or tap the map to lock your next city.",
|
||||
step1Cta: "Start discovering",
|
||||
step2Title: "Meet people",
|
||||
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",
|
||||
step3Title: "Visas & prep",
|
||||
step3Desc: "Browse popular nomad visas and stay rules.",
|
||||
step3Cta: "Visa guide",
|
||||
step4Title: "Toolkit",
|
||||
step4Desc: "Costs, jet lag, packing, scam tips — dozens of helpers on demand.",
|
||||
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: {
|
||||
mapTag: "🗺️ WORLD MAP",
|
||||
mapTitle: "Nomad heatmap",
|
||||
@ -868,6 +920,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
},
|
||||
footer: {
|
||||
explore: "Explore",
|
||||
connect: "Connect",
|
||||
grow: "Grow",
|
||||
tools: "Tools",
|
||||
account: "Account",
|
||||
destinations: "Destinations",
|
||||
@ -876,7 +930,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
visa: "Visas",
|
||||
blog: "Blog",
|
||||
about: "About nomadro",
|
||||
toolkit: "Toolkit",
|
||||
toolkit: "Utility tools",
|
||||
ebook: "Nomad Code · Ebook",
|
||||
planHub: "Plan 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