feat: fetch menu items from cms

This commit is contained in:
Zotov Ivan Yuryevich
2026-04-16 22:41:30 +03:00
parent 173708addb
commit 3d985584fe
9 changed files with 82 additions and 62 deletions

View File

@@ -2,7 +2,7 @@
import * as React from 'react' import * as React from 'react'
import { Slot } from '@radix-ui/react-slot' import { Slot } from '@radix-ui/react-slot'
import { VariantProps, cva } from 'class-variance-authority' import { type VariantProps, cva } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react' import { PanelLeftIcon } from 'lucide-react'
import { useIsMobile } from './use-mobile' import { useIsMobile } from './use-mobile'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner' import { Toaster as Sonner, type ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme() const { theme = 'system' } = useTheme()

View File

@@ -0,0 +1,13 @@
/* eslint-disable */
/* Generated by scripts/endpoint-generate.mjs — do not edit by hand */
import type { ExecutionResult } from "graphql";
import { execute } from "../graphql/execute";
import type { MenuItemsQuery, MenuItemsQueryVariables } from "../graphql/graphql";
import { menuItemsDocument } from "./menu-items.gql";
export const menuItemsQuery = {
async execute(variables?: MenuItemsQueryVariables): Promise<ExecutionResult<MenuItemsQuery>> {
return execute<MenuItemsQuery, MenuItemsQueryVariables>(menuItemsDocument, variables);
},
};

View File

@@ -0,0 +1,12 @@
import gql from 'graphql-tag'
export const menuItemsDocument = gql`
query MenuItems {
categories(pagination: { pageSize: 200 }) {
slug
title
group
sortOrder
}
}
`

View File

@@ -1573,6 +1573,11 @@ export type HomePageQuery = { __typename?: 'Query', homePage?: { __typename?: 'H
| { __typename: 'Error' } | { __typename: 'Error' }
| null> | null } | null }; | null> | null } | null };
export type MenuItemsQueryVariables = Exact<{ [key: string]: never; }>;
export type MenuItemsQuery = { __typename?: 'Query', categories: Array<{ __typename?: 'Category', slug: string, title?: string | null, group: Enum_Category_Group, sortOrder: number } | null> };
export type SiteConfigQueryVariables = Exact<{ [key: string]: never; }>; export type SiteConfigQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1704,6 +1709,16 @@ export const HomePageDocument = new TypedDocumentString(`
} }
} }
`) as unknown as TypedDocumentString<HomePageQuery, HomePageQueryVariables>; `) as unknown as TypedDocumentString<HomePageQuery, HomePageQueryVariables>;
export const MenuItemsDocument = new TypedDocumentString(`
query MenuItems {
categories(pagination: {pageSize: 200}) {
slug
title
group
sortOrder
}
}
`) as unknown as TypedDocumentString<MenuItemsQuery, MenuItemsQueryVariables>;
export const SiteConfigDocument = new TypedDocumentString(` export const SiteConfigDocument = new TypedDocumentString(`
query SiteConfig { query SiteConfig {
siteConfig { siteConfig {

View File

@@ -5,31 +5,27 @@ import { getCollection, type CollectionEntry } from 'astro:content'
import { Header } from '../sections/Header' import { Header } from '../sections/Header'
import { Footer } from '../sections/Footer' import { Footer } from '../sections/Footer'
import { Enum_Category_Group } from '../graphql/graphql' import { Enum_Category_Group } from '../graphql/graphql'
import { categoriesListQuery } from '../graphql-documents/categories.gql.generated'
import { CategoryView } from '../views/CategoryView' import { CategoryView } from '../views/CategoryView'
import type { CategoriesListQuery } from '../graphql/graphql' import type { MenuItemsQuery } from '../graphql/graphql'
import type { SiteConfigContact } from '../lib/cms/contact/types' import type { SiteConfigContact } from '../lib/cms/contact/types'
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'
export async function getStaticPaths() { export async function getStaticPaths() {
const categories = await getCollection('categories') const categories = await getCollection('categories')
const [siteResult, solutionCategoriesResult, serviceCategoriesResult] = await Promise.all([ const [siteResult, menuItemsResult] = await Promise.all([siteConfigQuery.execute(), menuItemsQuery.execute()])
siteConfigQuery.execute(),
categoriesListQuery.execute({ group: Enum_Category_Group.Solution }),
categoriesListQuery.execute({ group: Enum_Category_Group.Service }),
])
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 (solutionCategoriesResult.errors?.length) {
throw new Error(solutionCategoriesResult.errors.map(e => e.message).join(', '))
}
if (serviceCategoriesResult.errors?.length) {
throw new Error(serviceCategoriesResult.errors.map(e => e.message).join(', '))
}
const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? [] const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? []
const solutionCategories = solutionCategoriesResult.data?.categories?.filter(c => c != null) ?? [] if (menuItemsResult.errors?.length) {
const serviceCategories = serviceCategoriesResult.data?.categories?.filter(c => c != null) ?? [] throw new Error(menuItemsResult.errors.map(e => e.message).join(', '))
}
const menuItems = (menuItemsResult.data?.categories?.filter(c => c != null) ?? []).sort(
(a, b) => a.sortOrder - b.sortOrder
)
const solutionCategories = menuItems.filter(c => c.group === Enum_Category_Group.Solution)
const serviceCategories = menuItems.filter(c => c.group === Enum_Category_Group.Service)
return categories.map((entry: CollectionEntry<'categories'>) => ({ return categories.map((entry: CollectionEntry<'categories'>) => ({
params: { slug: entry.id }, params: { slug: entry.id },
@@ -53,8 +49,8 @@ interface Props {
products?: string[] products?: string[]
}[] }[]
contacts: SiteConfigContact[] contacts: SiteConfigContact[]
solutionCategories: NonNullable<CategoriesListQuery['categories'][number]>[] solutionCategories: NonNullable<MenuItemsQuery['categories'][number]>[]
serviceCategories: NonNullable<CategoriesListQuery['categories'][number]>[] serviceCategories: NonNullable<MenuItemsQuery['categories'][number]>[]
} }
const { title, description, vendors, contacts, solutionCategories, serviceCategories } = Astro.props as Props const { title, description, vendors, contacts, solutionCategories, serviceCategories } = Astro.props as Props
@@ -62,12 +58,7 @@ const { title, description, vendors, contacts, solutionCategories, serviceCatego
<Layout> <Layout>
<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 <Header client:load contacts={contacts} solutions={solutionCategories} services={serviceCategories} />
client:load
contacts={contacts}
solutionCategories={solutionCategories}
serviceCategories={serviceCategories}
/>
<CategoryView client:load title={title} description={description} vendors={vendors} /> <CategoryView client:load title={title} description={description} vendors={vendors} />
<Footer client:load contacts={contacts} solutionCategories={solutionCategories} /> <Footer client:load contacts={contacts} solutionCategories={solutionCategories} />
</div> </div>

View File

@@ -5,15 +5,14 @@ import { Header } from '../sections/Header'
import { Footer } from '../sections/Footer' import { Footer } from '../sections/Footer'
import { HomePage } from '../views/HomePage' import { HomePage } from '../views/HomePage'
import { Enum_Category_Group } from '../graphql/graphql' import { Enum_Category_Group } from '../graphql/graphql'
import { categoriesListQuery } from '../graphql-documents/categories.gql.generated' 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'
const [homeResult, siteResult, solutionCategoriesResult, serviceCategoriesResult] = await Promise.all([ const [homeResult, siteResult, menuItemsResult] = await Promise.all([
homePageQuery.execute(), homePageQuery.execute(),
siteConfigQuery.execute(), siteConfigQuery.execute(),
categoriesListQuery.execute({ group: Enum_Category_Group.Solution }), menuItemsQuery.execute(),
categoriesListQuery.execute({ group: Enum_Category_Group.Service }),
]) ])
if (homeResult.errors?.length) { if (homeResult.errors?.length) {
@@ -22,27 +21,22 @@ if (homeResult.errors?.length) {
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 (solutionCategoriesResult.errors?.length) { if (menuItemsResult.errors?.length) {
throw new Error(solutionCategoriesResult.errors.map(e => e.message).join(', ')) throw new Error(menuItemsResult.errors.map(e => e.message).join(', '))
}
if (serviceCategoriesResult.errors?.length) {
throw new Error(serviceCategoriesResult.errors.map(e => e.message).join(', '))
} }
const homePage = homeResult.data?.homePage ?? null const homePage = homeResult.data?.homePage ?? null
const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? [] const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? []
const solutionCategories = solutionCategoriesResult.data?.categories?.filter(c => c != null) ?? [] const menuItems = (menuItemsResult.data?.categories?.filter(c => c != null) ?? []).sort(
const serviceCategories = serviceCategoriesResult.data?.categories?.filter(c => c != null) ?? [] (a, b) => a.sortOrder - b.sortOrder
)
const solutionCategories = menuItems.filter(c => c.group === Enum_Category_Group.Solution)
const serviceCategories = menuItems.filter(c => c.group === Enum_Category_Group.Service)
--- ---
<Layout> <Layout>
<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 <Header client:load contacts={contacts} solutions={solutionCategories} services={serviceCategories} />
client:load
contacts={contacts}
solutionCategories={solutionCategories}
serviceCategories={serviceCategories}
/>
<HomePage client:load homePage={homePage} contacts={contacts} /> <HomePage client:load homePage={homePage} contacts={contacts} />
<Footer client:load contacts={contacts} solutionCategories={solutionCategories} /> <Footer client:load contacts={contacts} solutionCategories={solutionCategories} />
</div> </div>

View File

@@ -1,12 +1,14 @@
import { Send, Video } from 'lucide-react' import { Send, Video } from 'lucide-react'
import { SiteContactItems } from '../components/SiteContactItems' import { SiteContactItems } from '../components/SiteContactItems'
import type { CategoriesListQuery } from '../graphql/graphql' import type { MenuItemsQuery } from '../graphql/graphql'
import type { SiteConfigContact } from '../lib/cms/contact/types' import type { SiteConfigContact } from '../lib/cms/contact/types'
type CategoryNavItem = NonNullable<MenuItemsQuery['categories'][number]>
export interface FooterProps { export interface FooterProps {
contacts: readonly SiteConfigContact[] contacts: readonly SiteConfigContact[]
solutionCategories: ReadonlyArray<NonNullable<CategoriesListQuery['categories'][number]>> solutionCategories: CategoryNavItem[]
} }
export function Footer({ contacts, solutionCategories }: FooterProps) { export function Footer({ contacts, solutionCategories }: FooterProps) {
@@ -62,7 +64,7 @@ export function Footer({ contacts, solutionCategories }: FooterProps) {
<h4 className="text-white mb-6">Решения</h4> <h4 className="text-white mb-6">Решения</h4>
<ul className="space-y-3"> <ul className="space-y-3">
{solutionCategories.map(category => ( {solutionCategories.map(category => (
<li key={category.documentId}> <li key={category.slug}>
<a href={`/${category.slug}`} className="hover:text-blue-400 transition-colors"> <a href={`/${category.slug}`} className="hover:text-blue-400 transition-colors">
{category.title ?? category.slug} {category.title ?? category.slug}
</a> </a>

View File

@@ -1,27 +1,24 @@
import { Menu, X, ChevronDown } from 'lucide-react' 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 { CategoriesListQuery } from '../graphql/graphql' import type { MenuItemsQuery } from '../graphql/graphql'
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'
type CategoryNavItem = NonNullable<CategoriesListQuery['categories'][number]> type CategoryNavItem = NonNullable<MenuItemsQuery['categories'][number]>
export interface HeaderProps { export interface HeaderProps {
contacts: readonly SiteConfigContact[] contacts: readonly SiteConfigContact[]
solutionCategories: readonly CategoryNavItem[] solutions: readonly CategoryNavItem[]
serviceCategories: readonly CategoryNavItem[] services: readonly CategoryNavItem[]
} }
export function Header({ contacts, solutionCategories, serviceCategories }: HeaderProps) { export function Header({ contacts, solutions, services }: 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)
const [isContactModalOpen, setIsContactModalOpen] = useState(false) const [isContactModalOpen, setIsContactModalOpen] = useState(false)
const solutions = [...solutionCategories].sort((a, b) => a.sortOrder - b.sortOrder)
const services = [...serviceCategories].sort((a, b) => a.sortOrder - b.sortOrder)
return ( return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100"> <header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<nav className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16 py-4"> <nav className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16 py-4">
@@ -48,7 +45,7 @@ export function Header({ contacts, solutionCategories, serviceCategories }: Head
<div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200"> <div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200">
{solutions.map(solution => ( {solutions.map(solution => (
<a <a
key={solution.documentId} key={solution.slug}
href={`/${solution.slug}`} href={`/${solution.slug}`}
className="block w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors" className="block w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
> >
@@ -76,7 +73,7 @@ export function Header({ contacts, solutionCategories, serviceCategories }: Head
<div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200"> <div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200">
{services.map(service => ( {services.map(service => (
<a <a
key={service.documentId} key={service.slug}
href={`/${service.slug}`} href={`/${service.slug}`}
className="block w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors" className="block w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
> >
@@ -143,7 +140,7 @@ export function Header({ contacts, solutionCategories, serviceCategories }: Head
<div className="pl-4 mt-2 space-y-2"> <div className="pl-4 mt-2 space-y-2">
{solutions.map(solution => ( {solutions.map(solution => (
<a <a
key={solution.documentId} key={solution.slug}
href={`/${solution.slug}`} href={`/${solution.slug}`}
onClick={() => { onClick={() => {
setIsMenuOpen(false) setIsMenuOpen(false)
@@ -172,7 +169,7 @@ export function Header({ contacts, solutionCategories, serviceCategories }: Head
<div className="pl-4 mt-2 space-y-2"> <div className="pl-4 mt-2 space-y-2">
{services.map(service => ( {services.map(service => (
<a <a
key={service.documentId} key={service.slug}
href={`/${service.slug}`} href={`/${service.slug}`}
onClick={() => { onClick={() => {
setIsMenuOpen(false) setIsMenuOpen(false)
@@ -221,11 +218,7 @@ export function Header({ contacts, solutionCategories, serviceCategories }: Head
</div> </div>
)} )}
</nav> </nav>
<ContactModal <ContactModal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} contacts={contacts} />
isOpen={isContactModalOpen}
onClose={() => setIsContactModalOpen(false)}
contacts={contacts}
/>
</header> </header>
) )
} }