diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3e23f17..ab2981f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; + } +} diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx index e32d9f7..bc0dec3 100644 --- a/frontend/src/app/tools/page.tsx +++ b/frontend/src/app/tools/page.tsx @@ -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 => !!t), + () => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable => !!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 (
@@ -40,91 +47,14 @@ export default function ToolsHubPage() {

{t.tools.subtitle}

-
- - 🧭 -
- {t.tools.hubNextStop} -

{t.tools.hubNextStopDesc}

-
- - - 🎉 -
- {t.tools.hubMeetups} -

{t.tools.hubMeetupsDesc}

-
- - - 💬 -
- {t.tools.hubCommunity} -

{t.tools.hubCommunityDesc}

-
- -
- -
- - 💕 -
- {t.tools.hubDating} -

{t.tools.hubDatingDesc}

-
- - - ✉️ -
- {t.tools.hubChat} -

{t.tools.hubChatDesc}

-
- - - 🎓 -
- {t.tools.hubDigital} -

{t.tools.hubDigitalDesc}

-
- - - ✨ -
- {t.tools.hubJoin} -

{t.tools.hubJoinDesc}

-
- -
- -
- - 🗓️ -
- {t.tools.corePlan} -

{t.tools.corePlanDesc}

-
- - - ⚖️ -
- {t.tools.coreCompare} -

{t.tools.coreCompareDesc}

-
- - - ☁️ -
- {t.tools.coreSync} -

{t.tools.coreSyncDesc}

-
- -
+
- ✨ 本周推荐 - 一次上新 12+ 工具 · 周末/分账/防坑… + ✨ {t.tools.tag} + {featured.length} · 先用这几个
-
+
{featured.map((tool) => ( {tool.emoji} @@ -158,7 +88,7 @@ export default function ToolsHubPage() {
- {filtered.map((tool) => ( + {visible.map((tool) => ( {tool.emoji}

{tool.title}

@@ -167,9 +97,14 @@ export default function ToolsHubPage() { ))}
- {filtered.length === 0 && ( -

没有匹配的工具,试试其他关键词

+ {!showAll && !q.trim() && filter === "all" && filtered.length > 12 && ( +
+ +
)} + {filtered.length === 0 &&

没有匹配的工具,试试其他关键词

}
diff --git a/frontend/src/components/CommunityHubClient.tsx b/frontend/src/components/CommunityHubClient.tsx index 9a0303f..656c924 100644 --- a/frontend/src/components/CommunityHubClient.tsx +++ b/frontend/src/components/CommunityHubClient.tsx @@ -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 && (

{t.community.empty}

)} + + ); diff --git a/frontend/src/components/DatingClient.tsx b/frontend/src/components/DatingClient.tsx index cb7de1b..f5332f6 100644 --- a/frontend/src/components/DatingClient.tsx +++ b/frontend/src/components/DatingClient.tsx @@ -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("friends"); + const [moreIntents, setMoreIntents] = useState(false); const [deck, setDeck] = useState([]); const [quota, setQuota] = useState(null); const [loading, setLoading] = useState(true); @@ -118,7 +124,7 @@ export default function DatingClient() {
- {INTENTS.map((i) => ( + {(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => ( + )}
{quota && ( @@ -166,6 +177,8 @@ export default function DatingClient() { {!loading && joined && !top && (

{t.dating.empty}

)} + + {matchModal && ( diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx index 213037f..65d8624 100644 --- a/frontend/src/components/DestinationDetailClient.tsx +++ b/frontend/src/components/DestinationDetailClient.tsx @@ -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 = { 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(null); @@ -114,18 +116,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props )} - - {related.length > 0 && ( - d.slug)])} - className="btn btn-ghost" - > - ⚖️ 对比同区 - - )} 🍹 同城活动 - 🗓️ 计划中心 - 📋 签证 + {(visa || overBudget) && ( @@ -320,6 +312,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props )} + + ); } diff --git a/frontend/src/components/DigitalHomeClient.tsx b/frontend/src/components/DigitalHomeClient.tsx index e26ade9..60e68b9 100644 --- a/frontend/src/components/DigitalHomeClient.tsx +++ b/frontend/src/components/DigitalHomeClient.tsx @@ -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 }) 🎓

{t.digital.course}

-

{course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons}

+

+ {course.modules.length} {t.digital.modules} · {lessonCount} {t.digital.lessons} +

📖 @@ -41,6 +44,13 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })

{t.digital.vipDesc}

+ + ); diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 2fa60c9..a2201fa 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -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 ( @@ -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 (
-
+
-

{locale === "en" ? "Work and live freely anywhere on this planet 🌏" : "让每个人都能自由地工作和生活在这个星球上 🌏"}

-
- 𝕏 - 📷 - ▶️ - 💬 -
+

+ {locale === "en" + ? "Work and live freely anywhere on this planet 🌏" + : "让每个人都能自由地工作和生活在这个星球上 🌏"} +

{t.footer.explore}

{t.footer.destinations} - {!isHome && ( - <> - {t.footer.plan} - {t.footer.compare} - - )} - {t.footer.visa} - {t.footer.blog} {t.footer.nextStop} + {!isHome && {t.footer.plan}} + {t.footer.visa} +
+
+

{t.footer.connect}

{t.footer.meetups} {t.footer.community} {t.footer.dating} - {t.footer.gigs} - {t.footer.digital} - {t.footer.pricing} - {t.footer.videos} - {t.footer.services} - {t.footer.ai} - {t.footer.mapPage} - {t.footer.report} - {t.footer.about} + {t.footer.chat}
-

{t.footer.tools}

+

{t.footer.grow}

+ {t.footer.digital} + {t.footer.gigs} {t.footer.toolkit} - {t.footer.ebook} - {!isHome && ( - <> - {t.footer.planHub} - {t.footer.compareHub} - - )} - {t.footer.changelog} - API + {t.footer.join}

{t.footer.account}

{t.footer.login} - {t.footer.profile} - {t.footer.join} - {t.footer.chat} - {t.footer.notifications} - {t.footer.feedback} {t.footer.help} - {t.footer.contact} + {t.footer.about} {t.footer.privacy} hello@nomadro.com
-

© 2026 nomadro · {SITE.replace("https://", "")}

-

🌴 ☀️ 🏄‍♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅

+

© 2026 nomadro

diff --git a/frontend/src/components/GigsClient.tsx b/frontend/src/components/GigsClient.tsx index 66248c8..e0e63e1 100644 --- a/frontend/src/components/GigsClient.tsx +++ b/frontend/src/components/GigsClient.tsx @@ -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>({}); const apply = async (id: string) => { @@ -32,7 +35,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) { return (
- +
{t.gigs.tag}

{t.gigs.title}

@@ -50,6 +53,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) { ))}
+
); diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 2cb8e03..27693a4 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -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 (
@@ -129,32 +107,19 @@ export default function Hero({ stats, onOpenMatcher }: Props) {
- - {t.hero.ctaExplore} - - - {onOpenMatcher && ( - + ) : ( + + {t.hero.ctaExplore} + )} - {t.journey.title} -
-
-
- 0 - {t.hero.statCountries} -
-
-
- 0 - {t.hero.statCost} -
-
-
- 0 - {t.hero.statHappy} -
+ + {t.hero.ctaExplore} +
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 4c8411d..802f25b 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -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 =

(loader: () => Promise<{ default: import("react").ComponentType

}>) => 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) { +

+ +
+ @@ -118,7 +126,6 @@ export default function HomeClient(props: Props) {
- {matcherOpen && ( 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) {

{t.journey.title}

{t.journey.subtitle}

-
    +
      {steps.map((s, i) => (
    1. {i > 0 && } diff --git a/frontend/src/components/MeetupsClient.tsx b/frontend/src/components/MeetupsClient.tsx index 3d7e775..f22d80b 100644 --- a/frontend/src/components/MeetupsClient.tsx +++ b/frontend/src/components/MeetupsClient.tsx @@ -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 = { 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) { )} +
); diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index b0179df..bdabb66 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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(null); const [active, setActive] = useState(""); const [unread, setUnread] = useState(0); + const navRef = useRef(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: "/plan", label: t.nav.plan, section: "plan" }, - { href: "/compare", label: t.nav.compare, section: "compare" }, + { 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: "/#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 ( -