feat: add contacts from cms

This commit is contained in:
Zotov Ivan Yuryevich
2026-04-14 22:55:10 +03:00
parent 79896b4a52
commit 32ea9359ae
9 changed files with 162 additions and 80 deletions

View File

@@ -1,13 +1,17 @@
import { X, Mail, Phone, MapPin, Clock, Calendar } from 'lucide-react' import { X, Calendar } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { SiteContactItems } from './SiteContactItems'
import type { SiteConfigContact } from '../lib/cms/contact/types'
interface ContactModalProps { interface ContactModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
contacts: readonly SiteConfigContact[]
} }
export function ContactModal({ isOpen, onClose }: ContactModalProps) { export function ContactModal({ isOpen, onClose, contacts }: ContactModalProps) {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -198,27 +202,8 @@ export function ContactModal({ isOpen, onClose }: ContactModalProps) {
</div> </div>
</form> </form>
{/* Контакты компактно */} <div className="mt-8 pt-8 border-t border-gray-100">
<div className="mt-8 pt-8 border-t border-gray-100 flex flex-wrap justify-center gap-6 text-sm text-gray-600"> <SiteContactItems contacts={contacts} variant="inline" />
<a href="tel:+74951234567" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Phone className="w-4 h-4" />
<span>+7 (495) 123-45-67</span>
</a>
<a
href="mailto:info@softclick.ru"
className="flex items-center gap-2 hover:text-blue-600 transition-colors"
>
<Mail className="w-4 h-4" />
<span>info@softclick.ru</span>
</a>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>Москва, Пресненская наб., 12</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Пн-Пт 9:00–18:00</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,82 @@
import { Enum_Componentsharedcontact_Type } from '../graphql/graphql'
import { toIcon } from '../lib/cms/contact/toIcon'
import type { SiteConfigContact } from '../lib/cms/contact/types'
function hrefFor(contact: SiteConfigContact): string | undefined {
switch (contact.type) {
case Enum_Componentsharedcontact_Type.Phone:
return `tel:${contact.value.replace(/\s/g, '')}`
case Enum_Componentsharedcontact_Type.Email:
return `mailto:${contact.value}`
default:
return undefined
}
}
function labelFor(contact: SiteConfigContact): string {
const d = contact.displayValue?.trim()
return d || contact.value
}
export interface SiteContactItemsProps {
contacts: readonly SiteConfigContact[]
variant: 'inline' | 'footer'
/** Applied to the outer wrapper (inline: flex row; footer: ul gets section spacing from parent) */
className?: string
}
export function SiteContactItems({ contacts, variant, className }: SiteContactItemsProps) {
if (!contacts.length) return null
if (variant === 'footer') {
return (
<ul className={className ?? 'space-y-4'}>
{contacts.map(c => {
const Icon = toIcon(c.type)
const href = hrefFor(c)
const label = labelFor(c)
const content = href ? (
<a href={href} className="hover:text-blue-400 transition-colors">
{label}
</a>
) : (
<span>{label}</span>
)
return (
<li key={c.id} className="flex items-start gap-3">
<Icon className="w-5 h-5 mt-1 shrink-0 text-blue-400" />
<div>{content}</div>
</li>
)
})}
</ul>
)
}
const rowClass = className ?? 'flex flex-wrap justify-center gap-6 text-sm text-gray-600'
return (
<div className={rowClass}>
{contacts.map(c => {
const Icon = toIcon(c.type)
const href = hrefFor(c)
const label = labelFor(c)
const linkClass = 'flex items-center gap-2 hover:text-blue-600 transition-colors'
if (href) {
return (
<a key={c.id} href={href} className={linkClass}>
<Icon className="w-4 h-4" />
<span>{label}</span>
</a>
)
}
return (
<div key={c.id} className="flex items-center gap-2">
<Icon className="w-4 h-4" />
<span>{label}</span>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,5 @@
import type { SiteConfigQueryQuery } from '../../../graphql/graphql'
export type SiteConfigContact = NonNullable<
NonNullable<NonNullable<SiteConfigQueryQuery['siteConfig']>['contacts']>[number]
>

View File

@@ -5,15 +5,24 @@ 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 { CategoryView } from '../views/CategoryView' import { CategoryView } from '../views/CategoryView'
import type { SiteConfigContact } from '../lib/cms/contact/types'
import { siteConfigQueryQuery } from '../graphql-documents/site-config.gql.generated'
export async function getStaticPaths() { export async function getStaticPaths() {
const categories = await getCollection('categories') const categories = await getCollection('categories')
const siteResult = await siteConfigQueryQuery.execute()
if (siteResult.errors?.length) {
throw new Error(siteResult.errors.map(e => e.message).join(', '))
}
const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? []
return categories.map((entry: CollectionEntry<'categories'>) => ({ return categories.map((entry: CollectionEntry<'categories'>) => ({
params: { slug: entry.id }, params: { slug: entry.id },
props: { props: {
title: entry.data.title, title: entry.data.title,
description: entry.data.description, description: entry.data.description,
vendors: entry.data.vendors, vendors: entry.data.vendors,
contacts,
}, },
})) }))
} }
@@ -26,15 +35,16 @@ interface Props {
description: string description: string
products?: string[] products?: string[]
}[] }[]
contacts: SiteConfigContact[]
} }
const { title, description, vendors } = Astro.props as Props const { title, description, vendors, contacts } = Astro.props as Props
--- ---
<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 client:load /> <Header client:load contacts={contacts} />
<CategoryView client:load title={title} description={description} vendors={vendors} /> <CategoryView client:load title={title} description={description} vendors={vendors} />
<Footer client:load /> <Footer client:load contacts={contacts} />
</div> </div>
</Layout> </Layout>

View File

@@ -5,19 +5,28 @@ 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 { homePageQueryQuery } from '../graphql-documents/home-page.gql.generated' import { homePageQueryQuery } from '../graphql-documents/home-page.gql.generated'
import { siteConfigQueryQuery } from '../graphql-documents/site-config.gql.generated'
const result = await homePageQueryQuery.execute() const [homeResult, siteResult] = await Promise.all([
if (result.errors?.length) { homePageQueryQuery.execute(),
throw new Error(result.errors.map(e => e.message).join(', ')) siteConfigQueryQuery.execute(),
])
if (homeResult.errors?.length) {
throw new Error(homeResult.errors.map(e => e.message).join(', '))
}
if (siteResult.errors?.length) {
throw new Error(siteResult.errors.map(e => e.message).join(', '))
} }
const homePage = result.data?.homePage ?? null const homePage = homeResult.data?.homePage ?? null
const contacts = siteResult.data?.siteConfig?.contacts?.filter(c => c != null) ?? []
--- ---
<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 client:load /> <Header client:load contacts={contacts} />
<HomePage client:load homePage={homePage} /> <HomePage client:load homePage={homePage} contacts={contacts} />
<Footer client:load /> <Footer client:load contacts={contacts} />
</div> </div>
</Layout> </Layout>

View File

@@ -1,6 +1,11 @@
import { Phone, Mail, MapPin, Clock } from 'lucide-react' import { SiteContactItems } from '../components/SiteContactItems'
import type { SiteConfigContact } from '../lib/cms/contact/types'
export function Contact() { export interface ContactProps {
contacts: readonly SiteConfigContact[]
}
export function Contact({ contacts }: ContactProps) {
return ( return (
<section className="py-4 bg-gray-50"> <section className="py-4 bg-gray-50">
<div className="max-w-225 mx-auto px-6"> <div className="max-w-225 mx-auto px-6">
@@ -77,25 +82,11 @@ export function Contact() {
</form> </form>
</div> </div>
{/* Контакты компактно */} <SiteContactItems
<div className="mt-12 flex flex-wrap justify-center gap-8 text-sm text-gray-600"> contacts={contacts}
<a href="tel:+74951234567" className="flex items-center gap-2 hover:text-blue-600 transition-colors"> variant="inline"
<Phone className="w-4 h-4" /> className="mt-12 flex flex-wrap justify-center gap-8 text-sm text-gray-600"
<span>+7 (495) 123-45-67</span> />
</a>
<a href="mailto:info@softclick.ru" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Mail className="w-4 h-4" />
<span>info@softclick.ru</span>
</a>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>Москва, Пресненская наб., 12</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Пн-Пт 9:00–18:00</span>
</div>
</div>
</div> </div>
</section> </section>
) )

View File

@@ -1,6 +1,13 @@
import { Mail, Phone, MapPin, Send, Hash, Code, Video } from 'lucide-react' import { Send, Video } from 'lucide-react'
export function Footer() { import { SiteContactItems } from '../components/SiteContactItems'
import type { SiteConfigContact } from '../lib/cms/contact/types'
export interface FooterProps {
contacts: readonly SiteConfigContact[]
}
export function Footer({ contacts }: FooterProps) {
return ( return (
<footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800"> <footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800">
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16"> <div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
@@ -103,25 +110,7 @@ export function Footer() {
<div> <div>
<h4 className="text-white mb-6">Контакты</h4> <h4 className="text-white mb-6">Контакты</h4>
<ul className="space-y-4"> <SiteContactItems contacts={contacts} variant="footer" />
<li className="flex items-start gap-3">
<Phone className="w-5 h-5 mt-1 shrink-0 text-blue-400" />
<div>
<div className="text-white">+7 (495) 123-45-67</div>
<div>Пн-Пт 9:00-18:00</div>
</div>
</li>
<li className="flex items-start gap-3">
<Mail className="w-5 h-5 mt-1 shrink-0 text-blue-400" />
<a href="mailto:info@softclick.ru" className="hover:text-blue-400 transition-colors">
info@softclick.ru
</a>
</li>
<li className="flex items-start gap-3">
<MapPin className="w-5 h-5 mt-1 shrink-0 text-blue-400" />
<div>Москва, Пресненская наб., 12</div>
</li>
</ul>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,14 @@
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 { SiteConfigContact } from '../lib/cms/contact/types'
import { navigateHomeSectionAnchor } from '../lib/scrollToHash' import { navigateHomeSectionAnchor } from '../lib/scrollToHash'
export function Header() { export interface HeaderProps {
contacts: readonly SiteConfigContact[]
}
export function Header({ contacts }: 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)
@@ -223,7 +228,11 @@ export function Header() {
</div> </div>
)} )}
</nav> </nav>
<ContactModal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} /> <ContactModal
isOpen={isContactModalOpen}
onClose={() => setIsContactModalOpen(false)}
contacts={contacts}
/>
</header> </header>
) )
} }

View File

@@ -7,6 +7,7 @@ import { Cases } from '../sections/Cases'
import { About } from '../sections/About' import { About } from '../sections/About'
import { Contact } from '../sections/Contact' import { Contact } from '../sections/Contact'
import { ContactModal } from '../components/ContactModal' import { ContactModal } from '../components/ContactModal'
import type { SiteConfigContact } from '../lib/cms/contact/types'
import { scrollToHashTarget } from '../lib/scrollToHash' import { scrollToHashTarget } from '../lib/scrollToHash'
import type { Maybe } from '../lib/types' import type { Maybe } from '../lib/types'
@@ -57,9 +58,10 @@ function parseHomeSections(home: Maybe<HomePageData>) {
export interface HomePageProps { export interface HomePageProps {
homePage?: Maybe<HomePageData> homePage?: Maybe<HomePageData>
contacts: readonly SiteConfigContact[]
} }
export function HomePage({ homePage }: HomePageProps) { export function HomePage({ homePage, contacts }: HomePageProps) {
const [contactOpen, setContactOpen] = useState(false) const [contactOpen, setContactOpen] = useState(false)
const { hero, services, solutions, cases, about } = parseHomeSections(homePage ?? null) const { hero, services, solutions, cases, about } = parseHomeSections(homePage ?? null)
@@ -83,9 +85,9 @@ export function HomePage({ homePage }: HomePageProps) {
<About data={about} /> <About data={about} />
</div> </div>
<div id="contact" style={{ scrollMarginTop: HEADER_OFFSET_PX }}> <div id="contact" style={{ scrollMarginTop: HEADER_OFFSET_PX }}>
<Contact /> <Contact contacts={contacts} />
</div> </div>
<ContactModal isOpen={contactOpen} onClose={() => setContactOpen(false)} /> <ContactModal isOpen={contactOpen} onClose={() => setContactOpen(false)} contacts={contacts} />
</> </>
) )
} }