init
This commit is contained in:
22
src/components/CoachCard.astro
Normal file
22
src/components/CoachCard.astro
Normal 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>
|
||||
12
src/components/Marquee.astro
Normal file
12
src/components/Marquee.astro
Normal 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>
|
||||
85
src/components/MobileNavigation.tsx
Normal file
85
src/components/MobileNavigation.tsx
Normal 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
|
||||
90
src/components/Reviews.tsx
Normal file
90
src/components/Reviews.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/Section.astro
Normal file
34
src/components/Section.astro
Normal 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>
|
||||
39
src/components/ServiceCard.astro
Normal file
39
src/components/ServiceCard.astro
Normal 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>
|
||||
58
src/components/swiper/NavButtons.tsx
Normal file
58
src/components/swiper/NavButtons.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
src/components/swiper/Swiper.tsx
Normal file
39
src/components/swiper/Swiper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/components/swiper/hooks.ts
Normal file
14
src/components/swiper/hooks.ts
Normal 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])
|
||||
}
|
||||
1
src/components/swiper/index.ts
Normal file
1
src/components/swiper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Swiper';
|
||||
3
src/components/swiper/styles.css
Normal file
3
src/components/swiper/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.swiper-pagination-bullet-active {
|
||||
@apply bg-white;
|
||||
}
|
||||
Reference in New Issue
Block a user