This commit is contained in:
Salam-vsem
2025-05-12 20:46:39 +03:00
commit ba30833279
43 changed files with 8562 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
---
import { Image } from 'astro:assets'
type Props = {
name: string
description?: string
image: ImageMetadata
imageAlt: string
}
const { name, description, image, imageAlt } = Astro.props
---
<div class="flex h-full w-full flex-col items-center justify-start gap-4">
<div class="relative h-[390px] w-[300px]">
<Image src={image} class="absolute h-full w-full object-cover" alt={imageAlt} />
</div>
<div class="inline-flex w-full max-w-[300px] flex-col gap-1">
<h3 class="text-xl font-bold uppercase text-text-light">{name}</h3>
{description && <p>{description}</p>}
</div>
</div>

View File

@@ -0,0 +1,12 @@
---
type Props = {
texts: string[]
}
const { texts } = Astro.props
const repeatedTexts = texts.length < 10 ? Array.from({ length: 10 / texts.length }, () => texts).flat() : texts;
---
<div class="flex h-full w-max animate-marquee items-center gap-10 text-5xl font-bold uppercase">
{repeatedTexts.map((text) => <h2>{text}</h2>)}
</div>

View File

@@ -0,0 +1,85 @@
import { useTranslations } from '@/i18n/utils'
import { cn } from '@/libs/cn'
import React, { useState, useEffect, type ButtonHTMLAttributes } from 'react'
const t = useTranslations()
type AnimatedBurgerButtonProps = ButtonHTMLAttributes<unknown> & {
isOpen: boolean
}
const AnimatedBurgerButton: React.FC<AnimatedBurgerButtonProps> = ({ isOpen, ...buttonProps }) => (
<button
{...buttonProps}
className="fixed right-4 top-4 z-[60] flex h-8 w-8 cursor-pointer flex-col justify-around border-none bg-transparent p-0 focus:outline-none"
aria-label={isOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isOpen}
>
<div
className={cn(
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
isOpen ? 'translate-y-[11px] rotate-45' : ''
)}
/>
<div
className={cn(
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
isOpen ? 'opacity-0' : ''
)}
/>
<div
className={cn(
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
isOpen ? '-translate-y-[11px] -rotate-45' : ''
)}
/>
</button>
)
type MobileNavigationProps = {
navItems: readonly {
key: string
href: string
}[]
}
const MobileNavigation: React.FC<MobileNavigationProps> = ({ navItems }) => {
const [isOpen, setIsOpen] = useState(false)
const toggleMenu = () => {
setIsOpen(!isOpen)
}
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
return (
<div className="md:hidden">
<AnimatedBurgerButton isOpen={isOpen} onClick={toggleMenu} />
<div
className={cn(
'fixed left-0 top-0 z-50 w-full transform bg-bg-main p-6 pt-16 transition-transform duration-500 ease-in-out',
isOpen ? 'translate-y-0' : '-translate-y-full'
)}
>
<nav className="flex flex-col items-center space-y-8">
{navItems.map((item) => (
<a key={item.key} href={item.href} className="text-3xl" onClick={toggleMenu}>
{t(item.key as any)}
</a>
))}
</nav>
</div>
</div>
)
}
export default MobileNavigation

View File

@@ -0,0 +1,90 @@
import type { reviewsSchema } from '@/content.config'
import type { z } from 'astro:content'
import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/scrollbar'
import { Mousewheel, Scrollbar } from 'swiper/modules'
import StarIconSrc from '@/assets/icons/StarIcon.svg'
import { useTranslations } from '@/i18n/utils'
type Review = z.infer<ReturnType<typeof reviewsSchema>>
type ReviewsProps = {
reviews: Review[]
lang?: string
}
const REVIEWS_LINK =
'https://yandex.ru/maps/213/moscow/?ll=37.469982%2C55.781185&mode=poi&poi%5Bpoint%5D=37.469074%2C55.781319&poi%5Buri%5D=ymapsbm1%3A%2F%2Forg%3Foid%3D19715751392%26yclid%3D10576664134676381695%26banner_id%3D1873406736351323707&tab=reviews&z=17.75'
const swiperSlideClassName =
'!h-auto first:pl-0 last:pr-0 md:max-w-[600px] md:first:pl-24 md:last:pr-24'
export const Reviews: React.FC<ReviewsProps> = ({ reviews, lang }) => {
const t = useTranslations(lang)
return (
<Swiper
modules={[Scrollbar, Mousewheel]}
slidesPerView={1}
spaceBetween={24}
scrollbar={{
hide: true,
draggable: true,
}}
breakpoints={{
768: {
slidesPerView: 'auto',
},
}}
>
{reviews.map((review, index) => (
<SwiperSlide key={index} className={swiperSlideClassName}>
<div className="flex h-full flex-col gap-6 bg-bg-light p-6 md:px-14 md:py-16">
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<img key={i} {...StarIconSrc} alt="star" className="h-5 w-5 text-yellow-400" />
))}
</div>
<blockquote className="break-words text-lg font-semibold text-text-light">
{review.review}
</blockquote>
<div className="mt-auto flex items-center gap-4">
<img
{...review.author.image}
alt={review.author.imageAlt}
loading="lazy"
className="h-16 w-16 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="font-semibold text-text-light">{review.author.name}</span>
<span>{review.author.description}</span>
</div>
</div>
</div>
</SwiperSlide>
))}
<SwiperSlide className={swiperSlideClassName}>
<div className="h-full bg-bg-light transition-colors hover:bg-bg-light/80">
<a
href={REVIEWS_LINK}
target="_blank"
className="flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center md:px-14 md:py-16"
>
<span className="text-xl font-semibold text-text-light">
{t('reviews.viewMore.title')}
</span>
<span>{t('reviews.viewMore.description')}</span>
<svg
className="mt-2 h-6 w-6 text-text-light"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M5 12h14M12 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
</div>
</SwiperSlide>
</Swiper>
)
}

View File

@@ -0,0 +1,34 @@
---
import { cn } from '@/libs/cn'
type Props = {
title?: string
description?: string
className?: string
id?: string
}
const { title, description, className, id } = Astro.props
---
<section
class={cn(
'flex flex-col gap-6 py-10 px-6 md:p-24 md:gap-14',
className
)}
id={id}
>
{
(title || description) && (
<div class="flex w-full max-w-[750px] flex-col gap-6 self-center text-center">
{title && (
<h2 class="text-4xl font-bold leading-tight md:text-5xl">
<Fragment set:html={title} />
</h2>
)}
{description && <p>{description}</p>}
</div>
)
}
<slot />
</section>

View File

@@ -0,0 +1,39 @@
---
import type { servicesSchema } from '@/content.config'
import type { z } from 'astro:content'
type Props = z.infer<typeof servicesSchema> & { highlighted?: boolean }
const { title, charachteristics, highlighted = false, link } = Astro.props
---
<div
class:list={[
'flex h-full w-full flex-col border-2 px-7 py-8 md:max-w-[400px] md:min-h-[550px]',
'border border-transparent bg-card',
{ 'border-brand': highlighted },
]}
>
<div class="mb-8">
<h3 class="mb-4 text-2xl font-bold">{title}</h3>
</div>
<ul class="mb-16 flex-grow space-y-4">
{
charachteristics?.map((charachteristic) => (
<li class="flex items-start space-x-2 justify-between">
<span class="text-text-light">{charachteristic.text}</span>
{charachteristic.price && <span class="ml-2 text-brand text-nowrap">{charachteristic.price}</span>}
</li>
))
}
</ul>
{
link && (
<a href={link.href} target={link.target} class="brand-btn-outline">
{link.title}
</a>
)
}
</div>

View File

@@ -0,0 +1,58 @@
import type { ImageMetadata } from 'astro'
import React, { useEffect, type ButtonHTMLAttributes } from 'react'
import { useSwiper, type SwiperRef } from 'swiper/react'
import ChevronLeft from '@/assets/icons/LeftArrowIcon.svg'
import ChevronRight from '@/assets/icons/RightArrowIcon.svg'
import { cn } from '@/libs/cn'
import { useSlideChange } from './hooks'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { iconSrc: ImageMetadata }
export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
return (
<button
{...buttonProps}
className={cn(
'border-border-light hidden rounded-full border border-solid md:block',
buttonProps.disabled && 'border-none',
buttonProps.className
)}
>
<img {...iconSrc} width={50} height={50} />
</button>
)
}
export const PrevButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
const swiper = useSwiper()
const [canSlidePrev, setCanSlidePrev] = React.useState(false)
useSlideChange(() => setCanSlidePrev(!swiper.isBeginning))
return (
<NavButton
{...props}
className={cn('absolute left-0 top-1/2 z-10 -translate-y-1/2', props.className)}
iconSrc={ChevronLeft}
onClick={() => swiper.slidePrev()}
disabled={!canSlidePrev}
/>
)
}
export const NextButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
const swiper = useSwiper()
const [canSlideNext, setCanSlideNext] = React.useState(true)
useSlideChange(() => setCanSlideNext(!swiper.isEnd))
return (
<NavButton
{...props}
className={cn('absolute right-0 top-1/2 z-10 -translate-y-1/2', props.className)}
iconSrc={ChevronRight}
onClick={() => swiper.slideNext()}
disabled={!canSlideNext}
/>
)
}

View File

@@ -0,0 +1,39 @@
import React, { useCallback, useMemo, useRef } from 'react'
import { Swiper as SwiperBase, type SwiperRef, SwiperSlide } from 'swiper/react'
import { A11y, Navigation, Pagination } from 'swiper/modules'
import { cn } from '@/libs/cn'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import './styles.css'
import { NextButton, PrevButton } from './NavButtons'
type SwiperProps = {
images: (ImageMetadata & { alt: string })[]
className?: string
}
export const Swiper: React.FC<SwiperProps> = ({ images, className }) => {
return (
<SwiperBase
modules={[Navigation, Pagination, A11y]}
slidesPerView={1}
pagination={{
clickable: true,
}}
className={cn('h-full w-full', className)}
>
{images.map((image, index) => (
<SwiperSlide key={index}>
<img
{...image}
loading="lazy"
className="absolute h-full w-full object-cover md:px-[calc(50px+2rem)]"
/>
</SwiperSlide>
))}
<PrevButton />
<NextButton />
</SwiperBase>
)
}

View File

@@ -0,0 +1,14 @@
import { useEffect } from 'react'
import { useSwiper } from 'swiper/react'
export const useSlideChange = (onUpdate: VoidFunction) => {
const swiper = useSwiper()
useEffect(() => {
if (!swiper) return
swiper.on('slideChange', onUpdate)
return () => swiper.off('slideChange', onUpdate)
}, [swiper])
}

View File

@@ -0,0 +1 @@
export * from './Swiper';

View File

@@ -0,0 +1,3 @@
.swiper-pagination-bullet-active {
@apply bg-white;
}