feat: add contacts from cms
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
82
src/components/SiteContactItems.tsx
Normal file
82
src/components/SiteContactItems.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/lib/cms/contact/types.ts
Normal file
5
src/lib/cms/contact/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { SiteConfigQueryQuery } from '../../../graphql/graphql'
|
||||||
|
|
||||||
|
export type SiteConfigContact = NonNullable<
|
||||||
|
NonNullable<NonNullable<SiteConfigQueryQuery['siteConfig']>['contacts']>[number]
|
||||||
|
>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user