chore: show menu item only if section anchor provided

This commit is contained in:
Zotov Ivan Yuryevich
2026-06-17 22:46:41 +03:00
parent 5c7f09e37a
commit af201556c2
7 changed files with 152 additions and 51 deletions

View File

@@ -9,6 +9,7 @@ export const homePageDocument = gql`
id id
title title
description description
anchor
badge badge
stats { stats {
id id
@@ -20,6 +21,7 @@ export const homePageDocument = gql`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -39,6 +41,7 @@ export const homePageDocument = gql`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -56,6 +59,7 @@ export const homePageDocument = gql`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -75,6 +79,7 @@ export const homePageDocument = gql`
id id
title title
description description
anchor
partners(pagination: { pageSize: 100 }) { partners(pagination: { pageSize: 100 }) {
id id
value value

View File

@@ -1611,11 +1611,11 @@ export type HomePageQueryVariables = Exact<{ [key: string]: never; }>;
export type HomePageQuery = { __typename?: 'Query', homePage?: { __typename?: 'HomePage', sections?: Array< export type HomePageQuery = { __typename?: 'Query', homePage?: { __typename?: 'HomePage', sections?: Array<
| { __typename: 'ComponentSectionsAbout', id: string, title?: string | null, description?: string | null, partners?: Array<{ __typename?: 'ComponentSharedString', id: string, value: string } | null> | null, cards?: Array<{ __typename?: 'ComponentCardsAdvantage', id: string, title?: string | null, description?: string | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null } | null> | null } | { __typename: 'ComponentSectionsAbout', id: string, title?: string | null, description?: string | null, anchor?: string | null, partners?: Array<{ __typename?: 'ComponentSharedString', id: string, value: string } | null> | null, cards?: Array<{ __typename?: 'ComponentCardsAdvantage', id: string, title?: string | null, description?: string | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null } | null> | null }
| { __typename: 'ComponentSectionsCases', id: string, title?: string | null, description?: string | null, cards?: Array<{ __typename?: 'ComponentCardsCase', id: string, title?: string | null, description?: string | null, pretitle?: string | null, badge?: string | null, footnote?: string | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null } | null> | null } | { __typename: 'ComponentSectionsCases', id: string, title?: string | null, description?: string | null, anchor?: string | null, cards?: Array<{ __typename?: 'ComponentCardsCase', id: string, title?: string | null, description?: string | null, pretitle?: string | null, badge?: string | null, footnote?: string | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null } | null> | null }
| { __typename: 'ComponentSectionsHomeHero', id: string, title?: string | null, description?: string | null, badge?: string | null, stats?: Array<{ __typename?: 'ComponentSharedAttribute', id: string, key?: string | null, value?: string | null } | null> | null } | { __typename: 'ComponentSectionsHomeHero', id: string, title?: string | null, description?: string | null, anchor?: string | null, badge?: string | null, stats?: Array<{ __typename?: 'ComponentSharedAttribute', id: string, key?: string | null, value?: string | null } | null> | null }
| { __typename: 'ComponentSectionsServices', id: string, title?: string | null, description?: string | null, cards?: Array<{ __typename?: 'ComponentCardsService', id: string, title?: string | null, description?: string | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, category?: { __typename?: 'Category', slug: string } | null } | null> | null } | { __typename: 'ComponentSectionsServices', id: string, title?: string | null, description?: string | null, anchor?: string | null, cards?: Array<{ __typename?: 'ComponentCardsService', id: string, title?: string | null, description?: string | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null, color?: { __typename?: 'ComponentSharedColor', value?: Enum_Componentsharedcolor_Value | null } | null, category?: { __typename?: 'Category', slug: string } | null } | null> | null }
| { __typename: 'ComponentSectionsSolutions', id: string, title?: string | null, description?: string | null, cards?: Array<{ __typename?: 'ComponentCardsSolution', id: string, title?: string | null, description?: string | null, footnote?: string | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null, category?: { __typename?: 'Category', slug: string } | null } | null> | null } | { __typename: 'ComponentSectionsSolutions', id: string, title?: string | null, description?: string | null, anchor?: string | null, cards?: Array<{ __typename?: 'ComponentCardsSolution', id: string, title?: string | null, description?: string | null, footnote?: string | null, icon?: { __typename?: 'ComponentSharedIcon', value?: Enum_Componentsharedicon_Value | null } | null, category?: { __typename?: 'Category', slug: string } | null } | null> | null }
| { __typename: 'Error' } | { __typename: 'Error' }
| null> | null } | null }; | null> | null } | null };
@@ -1677,6 +1677,7 @@ export const HomePageDocument = new TypedDocumentString(`
id id
title title
description description
anchor
badge badge
stats { stats {
id id
@@ -1688,6 +1689,7 @@ export const HomePageDocument = new TypedDocumentString(`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -1707,6 +1709,7 @@ export const HomePageDocument = new TypedDocumentString(`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -1724,6 +1727,7 @@ export const HomePageDocument = new TypedDocumentString(`
id id
title title
description description
anchor
cards { cards {
id id
title title
@@ -1743,6 +1747,7 @@ export const HomePageDocument = new TypedDocumentString(`
id id
title title
description description
anchor
partners(pagination: {pageSize: 100}) { partners(pagination: {pageSize: 100}) {
id id
value value

View File

@@ -0,0 +1,46 @@
import type { HomePageQuery } from '../../../graphql/graphql'
import type { Maybe } from '../../types'
type HomePageData = NonNullable<HomePageQuery['homePage']>
/**
* Anchors configured on each home-page section, keyed by section kind. A key is
* present only when its section exists and has a non-empty `anchor`, which is
* what gates the matching header menu item.
*/
export interface HomeSectionAnchors {
hero?: string
services?: string
solutions?: string
cases?: string
about?: string
}
export function getHomeSectionAnchors(home: Maybe<HomePageData>): HomeSectionAnchors {
const anchors: HomeSectionAnchors = {}
for (const section of home?.sections ?? []) {
if (!section || section.__typename === 'Error') continue
const anchor = section.anchor?.trim()
if (!anchor) continue
switch (section.__typename) {
case 'ComponentSectionsHomeHero':
anchors.hero = anchor
break
case 'ComponentSectionsServices':
anchors.services = anchor
break
case 'ComponentSectionsSolutions':
anchors.solutions = anchor
break
case 'ComponentSectionsCases':
anchors.cases = anchor
break
case 'ComponentSectionsAbout':
anchors.about = anchor
break
}
}
return anchors
}

View File

@@ -8,12 +8,22 @@ import { CategoryView } from '../views/CategoryView'
import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated' import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated' import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
import { categoryQuery } from '../graphql-documents/category.gql.generated' import { categoryQuery } from '../graphql-documents/category.gql.generated'
import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
import { resolveUploadUrl } from '../lib/cms/uploads/url' import { resolveUploadUrl } from '../lib/cms/uploads/url'
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
const [siteResult, menuItemsResult] = await Promise.all([siteConfigQuery.execute(), menuItemsQuery.execute()]) const [siteResult, menuItemsResult, homeResult] = await Promise.all([
siteConfigQuery.execute(),
menuItemsQuery.execute(),
homePageQuery.execute(),
])
if (siteResult.errors?.length) { if (siteResult.errors?.length) {
throw new Error(siteResult.errors.map(e => e.message).join(', ')) throw new Error(siteResult.errors.map(e => e.message).join(', '))
} }
if (homeResult.errors?.length) {
throw new Error(homeResult.errors.map(e => e.message).join(', '))
}
const sectionAnchors = getHomeSectionAnchors(homeResult.data?.homePage ?? null)
const siteConfig = siteResult.data?.siteConfig const siteConfig = siteResult.data?.siteConfig
const siteTitle = siteConfig?.title ?? '' const siteTitle = siteConfig?.title ?? ''
const siteDescription = siteConfig?.description ?? '' const siteDescription = siteConfig?.description ?? ''
@@ -62,7 +72,7 @@ const vendors = (category.vendors?.filter(v => v != null) ?? []).map(vendor => (
description={description ?? siteDescription} description={description ?? siteDescription}
> >
<div class="min-h-screen bg-white w-full overflow-x-hidden"> <div class="min-h-screen bg-white w-full overflow-x-hidden">
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} /> <Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
<CategoryView client:load badge={badge} title={title} description={description} vendors={vendors} contacts={contacts} /> <CategoryView client:load badge={badge} title={title} description={description} vendors={vendors} contacts={contacts} />
<Footer <Footer
client:load client:load

View File

@@ -9,6 +9,7 @@ import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
import { homePageQuery } from '../graphql-documents/home-page.gql.generated' import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated' import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
import { resolveUploadUrl } from '../lib/cms/uploads/url' import { resolveUploadUrl } from '../lib/cms/uploads/url'
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
const [homeResult, siteResult, menuItemsResult] = await Promise.all([ const [homeResult, siteResult, menuItemsResult] = await Promise.all([
homePageQuery.execute(), homePageQuery.execute(),
@@ -39,11 +40,12 @@ const menuItems = (menuItemsResult.data?.categories?.filter(c => c != null) ?? [
const solutionCategories = menuItems.filter(c => c.group === Enum_Category_Group.Solution) const solutionCategories = menuItems.filter(c => c.group === Enum_Category_Group.Solution)
const serviceCategories = menuItems.filter(c => c.group === Enum_Category_Group.Service) const serviceCategories = menuItems.filter(c => c.group === Enum_Category_Group.Service)
const sectionAnchors = getHomeSectionAnchors(homePage)
--- ---
<Layout title={siteTitle} description={siteDescription}> <Layout title={siteTitle} description={siteDescription}>
<div class="min-h-screen bg-white w-full overflow-x-hidden"> <div class="min-h-screen bg-white w-full overflow-x-hidden">
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} /> <Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
<HomePage client:load homePage={homePage} contacts={contacts} /> <HomePage client:load homePage={homePage} contacts={contacts} />
<Footer <Footer
client:load client:load

View File

@@ -2,6 +2,7 @@ import { Menu, X, ChevronDown } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { ContactModal } from '../components/ContactModal' import { ContactModal } from '../components/ContactModal'
import type { MenuItemsQuery } from '../graphql/graphql' import type { MenuItemsQuery } from '../graphql/graphql'
import type { HomeSectionAnchors } from '../lib/cms/sections/homeNav'
import type { SiteConfigContact } from '../lib/cms/contact/types' import type { SiteConfigContact } from '../lib/cms/contact/types'
import { navigateHomeSectionAnchor } from '../lib/scrollToHash' import { navigateHomeSectionAnchor } from '../lib/scrollToHash'
@@ -12,9 +13,16 @@ export interface HeaderProps {
contacts?: readonly SiteConfigContact[] contacts?: readonly SiteConfigContact[]
solutions?: readonly CategoryNavItem[] solutions?: readonly CategoryNavItem[]
services?: readonly CategoryNavItem[] services?: readonly CategoryNavItem[]
sectionAnchors?: HomeSectionAnchors
} }
export function Header({ siteTitle = '', contacts = [], solutions = [], services = [] }: HeaderProps) { export function Header({
siteTitle = '',
contacts = [],
solutions = [],
services = [],
sectionAnchors = {},
}: HeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isSolutionsOpen, setIsSolutionsOpen] = useState(false) const [isSolutionsOpen, setIsSolutionsOpen] = useState(false)
const [isServicesOpen, setIsServicesOpen] = useState(false) const [isServicesOpen, setIsServicesOpen] = useState(false)
@@ -86,20 +94,24 @@ export function Header({ siteTitle = '', contacts = [], solutions = [], services
)} )}
</div> </div>
)} )}
{sectionAnchors.cases && (
<a <a
href="/#cases" href={`/#${sectionAnchors.cases}`}
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap" className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
onClick={e => navigateHomeSectionAnchor(e, 'cases')} onClick={e => navigateHomeSectionAnchor(e, sectionAnchors.cases!)}
> >
Кейсы Кейсы
</a> </a>
)}
{sectionAnchors.about && (
<a <a
href="/#about" href={`/#${sectionAnchors.about}`}
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap" className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
onClick={e => navigateHomeSectionAnchor(e, 'about')} onClick={e => navigateHomeSectionAnchor(e, sectionAnchors.about!)}
> >
О компании О компании
</a> </a>
)}
<a <a
href="/#contact" href="/#contact"
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap" className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
@@ -185,26 +197,30 @@ export function Header({ siteTitle = '', contacts = [], solutions = [], services
)} )}
</div> </div>
)} )}
{sectionAnchors.cases && (
<a <a
href="/#cases" href={`/#${sectionAnchors.cases}`}
onClick={e => { onClick={e => {
navigateHomeSectionAnchor(e, 'cases') navigateHomeSectionAnchor(e, sectionAnchors.cases!)
setIsMenuOpen(false) setIsMenuOpen(false)
}} }}
className="text-gray-700 hover:text-blue-600" className="text-gray-700 hover:text-blue-600"
> >
Кейсы Кейсы
</a> </a>
)}
{sectionAnchors.about && (
<a <a
href="/#about" href={`/#${sectionAnchors.about}`}
onClick={e => { onClick={e => {
navigateHomeSectionAnchor(e, 'about') navigateHomeSectionAnchor(e, sectionAnchors.about!)
setIsMenuOpen(false) setIsMenuOpen(false)
}} }}
className="text-gray-700 hover:text-blue-600" className="text-gray-700 hover:text-blue-600"
> >
О компании О компании
</a> </a>
)}
<a <a
href="/#contact" href="/#contact"
onClick={e => { onClick={e => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import type { HomePageQuery } from '../graphql/graphql' import type { HomePageQuery } from '../graphql/graphql'
import { Hero } from '../sections/Hero' import { Hero } from '../sections/Hero'
import { Services } from '../sections/Services' import { Services } from '../sections/Services'
@@ -56,6 +56,17 @@ function parseHomeSections(home: Maybe<HomePageData>) {
return { hero, services, solutions, cases, about } return { hero, services, solutions, cases, about }
} }
/** Wraps a section in an anchor target (id + header-offset scroll margin) when it has an anchor. */
function SectionAnchor({ anchor, children }: { anchor?: Maybe<string>; children: ReactNode }) {
const id = anchor?.trim()
if (!id) return <>{children}</>
return (
<div id={id} style={{ scrollMarginTop: HEADER_OFFSET_PX }}>
{children}
</div>
)
}
export interface HomePageProps { export interface HomePageProps {
homePage?: Maybe<HomePageData> homePage?: Maybe<HomePageData>
contacts?: readonly SiteConfigContact[] contacts?: readonly SiteConfigContact[]
@@ -75,15 +86,21 @@ export function HomePage({ homePage, contacts = [] }: HomePageProps) {
return ( return (
<> <>
<SectionAnchor anchor={hero?.anchor}>
<Hero onOpenContactModal={() => setContactOpen(true)} data={hero} /> <Hero onOpenContactModal={() => setContactOpen(true)} data={hero} />
</SectionAnchor>
<SectionAnchor anchor={services?.anchor}>
<Services data={services} /> <Services data={services} />
</SectionAnchor>
<SectionAnchor anchor={solutions?.anchor}>
<Solutions data={solutions} /> <Solutions data={solutions} />
<div id="cases" style={{ scrollMarginTop: HEADER_OFFSET_PX }}> </SectionAnchor>
<SectionAnchor anchor={cases?.anchor}>
<Cases data={cases} /> <Cases data={cases} />
</div> </SectionAnchor>
<div id="about" style={{ scrollMarginTop: HEADER_OFFSET_PX }}> <SectionAnchor anchor={about?.anchor}>
<About data={about} /> <About data={about} />
</div> </SectionAnchor>
<div id="contact" style={{ scrollMarginTop: HEADER_OFFSET_PX }}> <div id="contact" style={{ scrollMarginTop: HEADER_OFFSET_PX }}>
<Contact contacts={contacts} submissionSource="home_contact_section" /> <Contact contacts={contacts} submissionSource="home_contact_section" />
</div> </div>