reworked slider + added coaches page
@@ -1,21 +1,22 @@
|
||||
---
|
||||
import type { coachesSchema } from '@/content.config'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { Image } from 'astro:assets'
|
||||
import type { z } from 'astro:content'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
type Props = z.infer<ReturnType<typeof coachesSchema>> & { lang?: string }
|
||||
type Props = CollectionEntry<'coaches'> & { lang?: string }
|
||||
|
||||
const { lang, name, description, image, imageAlt, link } = Astro.props
|
||||
const { slug, lang, data } = Astro.props
|
||||
const { name, description, cover, coverAlt } = data
|
||||
const t = useTranslations(lang)
|
||||
const coachPageLink = `/coaches/${slug}`
|
||||
---
|
||||
|
||||
<div class="flex h-full w-full flex-col items-center justify-start gap-5">
|
||||
<a class="relative h-[390px] w-full max-w-[300px]" href={link} target="_blank">
|
||||
<a class="relative h-[390px] w-full max-w-[300px]" href={coachPageLink}>
|
||||
<Image
|
||||
src={image}
|
||||
src={cover}
|
||||
class="absolute h-full w-full object-cover"
|
||||
alt={imageAlt}
|
||||
alt={coverAlt}
|
||||
width={300}
|
||||
height={390}
|
||||
format="webp"
|
||||
@@ -26,12 +27,8 @@ const t = useTranslations(lang)
|
||||
<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>}
|
||||
{
|
||||
link && (
|
||||
<a href={link} target="_blank" class="mt-2 text-brand hover:text-brand/80">
|
||||
{t('coaches.link')}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<a href={coachPageLink} class="mt-2 text-brand hover:text-brand/80" data-astro-prefetch>
|
||||
{t('coaches.link')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,20 +4,12 @@ import { Image } from 'astro:assets'
|
||||
import logoSrc from '@/assets/logo.png'
|
||||
import type { z } from 'astro:content'
|
||||
import type { footerSchema } from '@/content.config'
|
||||
import type React from 'react'
|
||||
import { TelegramIcon } from '@/assets/icons/TelegramIcon'
|
||||
import { InstagramIcon } from '@/assets/icons/InstagramIcon'
|
||||
import type { IconProps } from '@/assets/icons/types'
|
||||
import { SocialMediaIcon } from './SocialMediaIcon'
|
||||
|
||||
type Props = z.infer<typeof footerSchema> & {
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const SOCIAL_MEDIA_ICONS_MAP: Record<string, React.ComponentType<IconProps>> = {
|
||||
telegram: TelegramIcon,
|
||||
instagram: InstagramIcon,
|
||||
}
|
||||
|
||||
const { lang, socialMediaLinks } = Astro.props
|
||||
const t = useTranslations(lang)
|
||||
---
|
||||
@@ -40,11 +32,12 @@ const t = useTranslations(lang)
|
||||
socialMediaLinks && (
|
||||
<div class="flex gap-4 text-text-light">
|
||||
{socialMediaLinks.map(({ href, target, icon }) => {
|
||||
const Icon = SOCIAL_MEDIA_ICONS_MAP[icon]
|
||||
|
||||
return (
|
||||
<a href={href} target={target} rel="noopener noreferrer">
|
||||
<Icon className="fill-text hover:fill-text-light transition-all duration-300" />
|
||||
<SocialMediaIcon
|
||||
type={icon}
|
||||
className="fill-text hover:fill-text-light transition-all duration-300"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { z } from 'astro:content'
|
||||
|
||||
type Props = z.infer<typeof servicesSchema> & { highlighted?: boolean }
|
||||
|
||||
const { title, charachteristics, highlighted = false, link } = Astro.props
|
||||
const { title, description, charachteristics, highlighted = false, link } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -14,8 +14,9 @@ const { title, charachteristics, highlighted = false, link } = Astro.props
|
||||
{ 'border-brand': highlighted },
|
||||
]}
|
||||
>
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-2xl font-bold">{title}</h3>
|
||||
<div class="mb-8 flex flex-col gap-2">
|
||||
<h3 class="text-2xl font-bold">{title}</h3>
|
||||
{description && <span>{description}</span>}
|
||||
</div>
|
||||
|
||||
<ul class="mb-16 flex-grow space-y-4">
|
||||
|
||||
22
src/components/SocialMediaIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { InstagramIcon } from '@/assets/icons/InstagramIcon'
|
||||
import { TelegramIcon } from '@/assets/icons/TelegramIcon'
|
||||
import type { IconProps } from '@/assets/icons/types'
|
||||
|
||||
export const socialMedias = ['telegram', 'instagram']
|
||||
export type SocialMedia = (typeof socialMedias)[number]
|
||||
|
||||
type SocialMediaIconProps = {
|
||||
type: SocialMedia
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SOCIAL_MEDIA_ICONS_MAP: Record<string, React.ComponentType<IconProps>> = {
|
||||
telegram: TelegramIcon,
|
||||
instagram: InstagramIcon,
|
||||
}
|
||||
|
||||
export const SocialMediaIcon: React.FC<SocialMediaIconProps> = ({ type, className }) => {
|
||||
const Icon = SOCIAL_MEDIA_ICONS_MAP[type]
|
||||
|
||||
return Icon ? <Icon className={className} /> : null
|
||||
}
|
||||
@@ -1,41 +1,42 @@
|
||||
---
|
||||
import type { TranslationKey } from '@/i18n/ui'
|
||||
import MobileNavigation from './MobileNavigation'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
|
||||
type Props = {
|
||||
export type HeaderProps = {
|
||||
navItems?: Array<{ key: TranslationKey; href: string }>
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const { lang } = Astro.props
|
||||
type Props = HeaderProps
|
||||
|
||||
const navItems = [
|
||||
{ key: 'nav.coaches', href: '#coaches' },
|
||||
{ key: 'nav.slider', href: '#image-slider' },
|
||||
{ key: 'nav.services', href: '#services' },
|
||||
{ key: 'nav.reviews', href: '#reviews' },
|
||||
{ key: 'nav.contact', href: '#contacts' },
|
||||
] as const
|
||||
const { lang, navItems } = Astro.props
|
||||
|
||||
const t = useTranslations(lang)
|
||||
---
|
||||
|
||||
<header
|
||||
transition:persist
|
||||
class="fixed left-0 right-0 top-0 z-50 flex h-[var(--header-height)] items-center justify-between bg-bg-main px-6 transition-transform duration-300 md:px-10"
|
||||
id="main-header"
|
||||
>
|
||||
<a class="text-2xl font-bold text-text-light" set:html={t('header.logo')} href="#hero" />
|
||||
<nav class="hidden md:block">
|
||||
<ul class="grid grid-flow-col gap-6">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{t(item.key)}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<MobileNavigation navItems={navItems} client:visible />
|
||||
<a class="text-2xl font-bold text-text-light" set:html={t('header.logo')} href="/" />
|
||||
{
|
||||
navItems && (
|
||||
<>
|
||||
<nav class="hidden md:block">
|
||||
<ul class="grid grid-flow-col gap-6">
|
||||
{navItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{t(item.key)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<MobileNavigation navItems={navItems} client:visible />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ImageMetadata } from 'astro'
|
||||
import React, { type ButtonHTMLAttributes } from 'react'
|
||||
import { useSwiper } 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 }
|
||||
|
||||
@@ -13,7 +11,7 @@ export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'hidden rounded-full border border-solid border-border-light md:block',
|
||||
'rounded-full border border-solid border-border-light',
|
||||
buttonProps.disabled && 'border-none',
|
||||
buttonProps.className
|
||||
)}
|
||||
@@ -24,35 +22,9 @@ export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
return <NavButton {...props} iconSrc={ChevronLeft} />
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
return <NavButton {...props} iconSrc={ChevronRight} />
|
||||
}
|
||||
|
||||
43
src/components/swiper/SimpleSwiper.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Swiper, type SwiperProps, 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'
|
||||
|
||||
export type SimpleSwiperProps = SwiperProps & {
|
||||
images: ImportAttributes[]
|
||||
}
|
||||
|
||||
export const SimpleSwiper = React.forwardRef<SwiperRef, SimpleSwiperProps>(
|
||||
({ images, className, modules = [], slideClass, ...otherProps }, ref) => {
|
||||
return (
|
||||
<Swiper
|
||||
ref={ref}
|
||||
modules={[Navigation, Pagination, A11y, ...modules]}
|
||||
slidesPerView={1}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
}}
|
||||
loop={true}
|
||||
watchSlidesProgress={true}
|
||||
className={cn('h-full w-full', className)}
|
||||
{...otherProps}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<img
|
||||
{...image}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchPriority={index === 0 ? 'high' : 'auto'}
|
||||
className={cn('absolute h-full w-full object-contain', slideClass)}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
)
|
||||
}
|
||||
)
|
||||
SimpleSwiper.displayName = 'SimpleSwiper'
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Swiper as SwiperBase, SwiperSlide } from 'swiper/react'
|
||||
import { A11y, Navigation, Pagination } from 'swiper/modules' // Added Lazy module
|
||||
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: ImportAttributes[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Swiper: React.FC<SwiperProps> = ({ images, className }) => {
|
||||
return (
|
||||
<SwiperBase
|
||||
modules={[Navigation, Pagination, A11y]}
|
||||
slidesPerView={1}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
}}
|
||||
watchSlidesProgress={true}
|
||||
className={cn('h-full w-full', className)}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<img
|
||||
{...image}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchPriority={index === 0 ? 'high' : 'auto'}
|
||||
className="absolute h-full w-full object-cover md:px-[calc(50px+2rem)]"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
<PrevButton />
|
||||
<NextButton />
|
||||
</SwiperBase>
|
||||
)
|
||||
}
|
||||
21
src/components/swiper/SwiperWithNav.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { type SwiperRef } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import { NextButton, PrevButton } from './NavButtons'
|
||||
import { useNavHandlers } from './hooks'
|
||||
import { SimpleSwiper, type SimpleSwiperProps } from './SimpleSwiper'
|
||||
|
||||
export const SwiperWithNav: React.FC<SimpleSwiperProps> = (props) => {
|
||||
const swiperRef = useRef<SwiperRef | null>(null)
|
||||
const { handleNext, handlePrev } = useNavHandlers(swiperRef)
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-10">
|
||||
<PrevButton className="hidden md:block" onClick={handlePrev} />
|
||||
<SimpleSwiper {...props} ref={swiperRef} />
|
||||
<NextButton className="hidden md:block" onClick={handleNext} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/components/swiper/SwiperWithThumbs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { Swiper, SwiperSlide, type SwiperClass, type SwiperRef } from 'swiper/react'
|
||||
import { FreeMode, Navigation, Thumbs } from 'swiper/modules'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/thumbs'
|
||||
import 'swiper/css/free-mode'
|
||||
import { cn } from '@/libs/cn'
|
||||
import { useNavHandlers } from './hooks'
|
||||
import { PrevButton, NextButton } from './NavButtons'
|
||||
import { SimpleSwiper } from './SimpleSwiper'
|
||||
|
||||
type SwiperProps = {
|
||||
images: ImportAttributes[]
|
||||
thumbs: ImportAttributes[]
|
||||
swiperClassName?: string
|
||||
}
|
||||
|
||||
export const SwiperWithThumbs: React.FC<SwiperProps> = ({ images, thumbs, swiperClassName }) => {
|
||||
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperClass | null>(null)
|
||||
const swiperRef = useRef<SwiperRef | null>(null)
|
||||
const { handleNext, handlePrev } = useNavHandlers(swiperRef)
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 items-center gap-x-10 gap-y-3 md:grid-cols-[auto_1fr_auto]">
|
||||
<PrevButton className="hidden md:block" onClick={handlePrev} />
|
||||
<SimpleSwiper
|
||||
modules={[Thumbs]}
|
||||
thumbs={{ swiper: thumbsSwiper }}
|
||||
watchSlidesProgress={true}
|
||||
className={cn(swiperClassName)}
|
||||
images={images}
|
||||
ref={swiperRef}
|
||||
pagination={false}
|
||||
/>
|
||||
<NextButton className="hidden md:block" onClick={handleNext} />
|
||||
<Swiper
|
||||
onSwiper={setThumbsSwiper}
|
||||
spaceBetween={12}
|
||||
slidesPerView={4}
|
||||
freeMode={true}
|
||||
watchSlidesProgress={true}
|
||||
modules={[Navigation, Thumbs, FreeMode]}
|
||||
// !!! TODO FIXME тут хардкод высоты так как если ставить в процентах, то появляется много пустого места снизу
|
||||
className="relative h-24 w-full justify-center place-self-start md:col-start-2"
|
||||
>
|
||||
{thumbs.map((image, index) => (
|
||||
<SwiperSlide
|
||||
key={index}
|
||||
className="w-1/4 opacity-40 [&.swiper-slide-thumb-active]:opacity-100"
|
||||
>
|
||||
<img
|
||||
{...image}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSwiper } from 'swiper/react'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
import type { SwiperRef } from 'swiper/react'
|
||||
|
||||
export const useSlideChange = (onUpdate: VoidFunction) => {
|
||||
const swiper = useSwiper()
|
||||
export const useNavHandlers = (swiperRef: RefObject<SwiperRef | null>) => {
|
||||
const handlePrev = useCallback(() => {
|
||||
if (!swiperRef.current) return
|
||||
swiperRef.current.swiper.slidePrev()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!swiper) return
|
||||
const handleNext = useCallback(() => {
|
||||
if (!swiperRef.current) return
|
||||
swiperRef.current.swiper.slideNext()
|
||||
}, [])
|
||||
|
||||
swiper.on('slideChange', onUpdate)
|
||||
|
||||
return () => swiper.off('slideChange', onUpdate)
|
||||
}, [swiper])
|
||||
return { handlePrev, handleNext } as const
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './Swiper'
|
||||
export * from './SwiperWithNav'
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.swiper-pagination-bullet-active {
|
||||
@apply bg-white;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { defineCollection, z, type ImageFunction } from 'astro:content'
|
||||
import { defineCollection, reference, z, type ImageFunction } from 'astro:content'
|
||||
|
||||
export const socialMediaLinkSchema = z.object({
|
||||
icon: z.string(),
|
||||
href: z.string(),
|
||||
target: z.string().optional(),
|
||||
})
|
||||
|
||||
export const heroSchema = (image: ImageFunction) =>
|
||||
z.object({
|
||||
@@ -15,14 +21,17 @@ export const heroSchema = (image: ImageFunction) =>
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const coachesSchema = (image: ImageFunction) =>
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
image: image(),
|
||||
imageAlt: z.string(),
|
||||
link: z.string().optional(),
|
||||
})
|
||||
export const coaches = defineCollection({
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
cover: image(),
|
||||
coverAlt: z.string(),
|
||||
gallery: z.array(image()).optional(),
|
||||
socialMediaLinks: z.array(socialMediaLinkSchema).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const servicesSchema = z.object({
|
||||
title: z.string(),
|
||||
@@ -57,15 +66,7 @@ export const reviewsSchema = (image: ImageFunction) =>
|
||||
|
||||
export const footerSchema = z.object({
|
||||
copyright: z.string().optional(),
|
||||
socialMediaLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
icon: z.string(),
|
||||
href: z.string(),
|
||||
target: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
socialMediaLinks: z.array(socialMediaLinkSchema).optional(),
|
||||
})
|
||||
|
||||
const pages = defineCollection({
|
||||
@@ -73,7 +74,6 @@ const pages = defineCollection({
|
||||
z.object({
|
||||
hero: heroSchema(image).optional(),
|
||||
marquee: z.array(z.string()).optional(),
|
||||
coaches: z.array(coachesSchema(image)).optional(),
|
||||
reviews: z.array(reviewsSchema(image)).optional(),
|
||||
slider: z
|
||||
.array(
|
||||
@@ -90,4 +90,5 @@ const pages = defineCollection({
|
||||
|
||||
export const collections = {
|
||||
pages,
|
||||
coaches,
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
BIN
src/content/coaches/assets/batyr-slide-1.jpeg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
src/content/coaches/assets/batyr-slide-2.jpeg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
src/content/coaches/assets/batyr-slide-3.jpeg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/content/coaches/assets/batyr-slide-4.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
src/content/coaches/assets/batyr-slide-5.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/content/coaches/assets/batyr-slide-6.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
39
src/content/coaches/batyr-suleimanov.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
cover: './assets/batyr-cover.jpeg'
|
||||
coverAlt: 'batyr-suleimanov'
|
||||
name: 'Батыр Сулейманов'
|
||||
description: 'Профессиональный выступающий спортсмен и тренер. Опыт работы в фитнес индустрии более 12 лет'
|
||||
gallery:
|
||||
- './assets/batyr-slide-1.jpeg'
|
||||
- './assets/batyr-slide-2.jpeg'
|
||||
- './assets/batyr-slide-3.jpeg'
|
||||
- './assets/batyr-slide-4.jpg'
|
||||
- './assets/batyr-slide-5.jpg'
|
||||
- './assets/batyr-slide-6.jpg'
|
||||
---
|
||||
🏆ЧЕМПИОН DUDUSHKIN FF 2022
|
||||
|
||||
🏆ЧЕМПИОН NPC REGIONAL CUP MOSCOW 2024
|
||||
|
||||
🏆ЧЕМПИОН OLYMPIA AMATEUR 2024 SOUTH KOREA
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН SPS 2022
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН OKSANA GRISHINA CLASSIC 2023
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН
|
||||
MUSCLE MULISHA GRANDPRIX 2023
|
||||
|
||||
🏆TOP-5 DUBAI PRO 2024
|
||||
|
||||
Профессиональный выступающий спортсмен и тренер.
|
||||
Опыт работы в фитнес индустрии более 12 лет.
|
||||
|
||||
Образование:
|
||||
1. Школа бодибилдинга и фитнеса им. Б. Вейдера 2014г.
|
||||
2. Ассоциация профессионалов фитнеса (FPA) 2015г.
|
||||
|
||||
Специализация:
|
||||
Строительство тела (набор мышечной массы, сжигание подкожно-жировой клетчатки без вреда для организма), составление плана и методики тренировочного процесса, разработка и контроль рациона питания (БЖУ индивидуально под каждого подопечного).
|
||||
Спортивное питание. Контроль анализов. Рекомендации по приему БАДов и витаминов.
|
||||
Подготовка к соревнованиям по бодибилдингу. Опыт в подготовке бойцов ММА (весогонка, «витаминная» поддержка).
|
||||
@@ -8,11 +8,7 @@ hero:
|
||||
title: 'Записаться'
|
||||
href: '#services'
|
||||
marquee:
|
||||
- ДОСТИГАЙТЕ ЦЕЛЕЙ
|
||||
- БУДЬТЕ СИЛЬНЕЕ
|
||||
- ТРЕНИРУЙТЕСЬ С НАМИ
|
||||
- МЕНЯЙТЕСЬ К ЛУЧШЕМУ
|
||||
- СТАНОВИТЕСЬ ЗДОРОВЕЕ
|
||||
- Ваш идеальный результат — в шаге от дома!
|
||||
slider:
|
||||
- image: './assets/slider/IMG_6491.jpg'
|
||||
imageAlt: 'first-slide'
|
||||
@@ -48,17 +44,6 @@ slider:
|
||||
imageAlt: 'sixteenth-slide'
|
||||
- image: './assets/slider/IMG_6520.jpg'
|
||||
imageAlt: 'seventeenth-slide'
|
||||
coaches:
|
||||
- image: './assets/coaches/1.jpeg'
|
||||
imageAlt: 'first-coach'
|
||||
name: 'БАТЫР СУЛЕЙМАНОВ'
|
||||
description: 'Профессиональный выступающий спортсмен и тренер. Опыт работы в фитнес индустрии более 12 лет'
|
||||
link: 'https://t.me/therealskill_studio/4'
|
||||
- image: './assets/coaches/2.jpeg'
|
||||
imageAlt: 'second-coach'
|
||||
name: 'СУЛЕЙМАНОВА АЛЕКСАНДРА'
|
||||
description: 'Спортсмен и тренер. Опыт работы в фитнес индустрии 9 лет'
|
||||
link: 'https://t.me/therealskill_studio/7'
|
||||
services:
|
||||
- title: 'Разовые Персональные тренировки с тренером'
|
||||
link:
|
||||
@@ -82,7 +67,7 @@ services:
|
||||
price: '60 000 ₽'
|
||||
- text: 'Блок из 15 тренировок СПЛИТ'
|
||||
price: '110 000 ₽'
|
||||
- title: 'Безлимитные Абонементы на месяц'
|
||||
- title: 'Безлимитные Абонементы'
|
||||
description: '30 календарных дней занятий с тренером'
|
||||
link:
|
||||
title: 'Записаться'
|
||||
|
||||
@@ -3,6 +3,8 @@ export const languages = {
|
||||
ru: 'Русский',
|
||||
}
|
||||
|
||||
export type TranslationKey = keyof (typeof ui)[keyof typeof ui]
|
||||
|
||||
export const ui = {
|
||||
ru: {
|
||||
'seo.meta.description':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ui } from './ui'
|
||||
import { ui, type TranslationKey } from './ui'
|
||||
|
||||
import { i18n } from 'astro:config/client'
|
||||
|
||||
@@ -11,7 +11,7 @@ export function getLangFromUrl(url: URL) {
|
||||
}
|
||||
|
||||
export function useTranslations(lang?: string) {
|
||||
return function t(key: keyof (typeof ui)[keyof typeof ui]) {
|
||||
return function t(key: TranslationKey) {
|
||||
return ui[lang as keyof typeof ui]?.[key] || ui[i18n!.defaultLocale as keyof typeof ui][key]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { cn } from '@/libs/cn'
|
||||
import '@/styles/global.css'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
bodyClass?: string
|
||||
}
|
||||
|
||||
const { title } = Astro.props
|
||||
const { title, bodyClass } = Astro.props
|
||||
const t = useTranslations(Astro.currentLocale)
|
||||
---
|
||||
|
||||
@@ -26,8 +29,9 @@ const t = useTranslations(Astro.currentLocale)
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href={Astro.url} />
|
||||
<title>{`${title} | ${t('seo.og.title')}`}</title>
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="flex flex-col">
|
||||
<body class={cn('flex flex-col', bodyClass)}>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import type { GetImageResult } from 'astro'
|
||||
import { TW_SCREENS, TW_WIDTHS } from '@/constants/images'
|
||||
import type { GetImageResult, UnresolvedImageTransform } from 'astro'
|
||||
import { getImage } from 'astro:assets'
|
||||
|
||||
export const imageResultToImageAttributes = (imageResult: GetImageResult): ImportAttributes => ({
|
||||
src: imageResult.src,
|
||||
srcSet: imageResult.srcSet?.attribute,
|
||||
...imageResult.attributes,
|
||||
})
|
||||
|
||||
export const optimizeSliderImages = (images: Pick<UnresolvedImageTransform, 'src' | 'alt'>[]) =>
|
||||
Promise.all(
|
||||
images.map(async ({ src, alt }) =>
|
||||
getImage({
|
||||
src,
|
||||
alt,
|
||||
widths: [TW_SCREENS.SM, TW_SCREENS.LG],
|
||||
sizes: `(max-width: ${TW_WIDTHS.LG}px) ${TW_SCREENS.SM}px, ${TW_SCREENS.LG}px`,
|
||||
quality: 80,
|
||||
format: 'webp',
|
||||
loading: 'lazy',
|
||||
}).then(imageResultToImageAttributes)
|
||||
)
|
||||
)
|
||||
|
||||
export const optimizeSliderThumbs = (thumbs: Pick<UnresolvedImageTransform, 'src' | 'alt'>[]) =>
|
||||
Promise.all(
|
||||
thumbs.map(async ({ src, alt }) =>
|
||||
getImage({
|
||||
src,
|
||||
alt,
|
||||
widths: [TW_SCREENS.XXS, TW_SCREENS.XS],
|
||||
sizes: `(max-width: ${TW_WIDTHS.LG}px) ${TW_SCREENS.XXS}px, ${TW_SCREENS.XS}px`,
|
||||
quality: 60,
|
||||
format: 'webp',
|
||||
loading: 'lazy',
|
||||
}).then(imageResultToImageAttributes)
|
||||
)
|
||||
)
|
||||
|
||||
48
src/pages/coaches/[...slug].astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro'
|
||||
import { getCollection } from 'astro:content'
|
||||
import { optimizeSliderImages, optimizeSliderThumbs } from '@/libs/image'
|
||||
import Section from '@/components/Section.astro'
|
||||
import { SwiperWithThumbs } from '@/components/swiper/SwiperWithThumbs'
|
||||
import Header from '@/components/header/Header.astro'
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const coaches = await getCollection('coaches')
|
||||
return coaches.map(({ slug, ...props }) => ({ params: { slug }, props }))
|
||||
}
|
||||
|
||||
const { data, render } = Astro.props
|
||||
const { gallery, cover } = data
|
||||
const { Content } = await render()
|
||||
const galleryWithCover = gallery ? [cover, ...gallery] : undefined
|
||||
const [thumbs, optimizedGallery] = galleryWithCover
|
||||
? await Promise.all([
|
||||
optimizeSliderThumbs(
|
||||
galleryWithCover.map((src, index) => ({ src, alt: `gallery-thumb-${index}` }))
|
||||
),
|
||||
optimizeSliderImages(
|
||||
galleryWithCover.map((src, index) => ({ src, alt: `gallery-slide-${index}` }))
|
||||
),
|
||||
])
|
||||
: [undefined, undefined]
|
||||
---
|
||||
|
||||
<BaseLayout title={data.name}>
|
||||
<Header />
|
||||
{
|
||||
optimizedGallery && (
|
||||
<Section>
|
||||
<SwiperWithThumbs
|
||||
swiperClassName="h-[540px]"
|
||||
client:load
|
||||
images={optimizedGallery}
|
||||
thumbs={thumbs}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
<Section>
|
||||
<Content />
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import Section from '@/components/Section.astro'
|
||||
import { Swiper } from '@/components/swiper'
|
||||
import Header from '@/components/header/Header.astro'
|
||||
import { SwiperWithNav } from '@/components/swiper'
|
||||
import Header, { type HeaderProps } from '@/components/header/Header.astro'
|
||||
import Footer from '@/components/Footer.astro'
|
||||
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
@@ -14,28 +14,24 @@ import Marquee from '@/components/Marquee.astro'
|
||||
import { Reviews, type OptimizedReview } from '@/components/Reviews.tsx'
|
||||
import Hero from '@/components/Hero.astro'
|
||||
import { getImage } from 'astro:assets'
|
||||
import { TW_SCREENS, TW_WIDTHS } from '@/constants/images'
|
||||
import { imageResultToImageAttributes } from '@/libs/image'
|
||||
import { imageResultToImageAttributes, optimizeSliderImages } from '@/libs/image'
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
type Props = CollectionEntry<'pages'>['data']
|
||||
|
||||
const navItems: HeaderProps['navItems'] = [
|
||||
{ key: 'nav.coaches', href: '#coaches' },
|
||||
{ key: 'nav.slider', href: '#image-slider' },
|
||||
{ key: 'nav.services', href: '#services' },
|
||||
{ key: 'nav.reviews', href: '#reviews' },
|
||||
{ key: 'nav.contact', href: '#contacts' },
|
||||
]
|
||||
|
||||
const { lang } = Astro.params
|
||||
const page = await getEntry('pages', 'main')
|
||||
const { slider, coaches, services, hero, marquee, reviews, footer } = page?.data || {}
|
||||
const [page, coaches] = await Promise.all([getEntry('pages', 'main'), getCollection('coaches')])
|
||||
const { slider, services, hero, marquee, reviews, footer } = page?.data || {}
|
||||
const optimizedSlider = slider
|
||||
? await Promise.all(
|
||||
slider.map((slide) =>
|
||||
getImage({
|
||||
src: slide.image,
|
||||
alt: slide.imageAlt,
|
||||
widths: [TW_SCREENS.SM, TW_SCREENS.LG],
|
||||
sizes: `(max-width: ${TW_WIDTHS.LG}px) ${TW_SCREENS.SM}px, ${TW_SCREENS.LG}px`,
|
||||
quality: 80,
|
||||
format: 'webp',
|
||||
loading: 'eager',
|
||||
}).then(imageResultToImageAttributes)
|
||||
)
|
||||
)
|
||||
? await optimizeSliderImages(slider.map(({ image, imageAlt }) => ({ src: image, alt: imageAlt })))
|
||||
: undefined
|
||||
|
||||
const optimizedReviews = reviews
|
||||
@@ -61,7 +57,7 @@ const t = useTranslations(lang)
|
||||
---
|
||||
|
||||
<BaseLayout title={t('home.title')}>
|
||||
<Header lang={lang} />
|
||||
<Header lang={lang} navItems={navItems} />
|
||||
|
||||
{hero && <Hero {...hero} />}
|
||||
|
||||
@@ -74,7 +70,7 @@ const t = useTranslations(lang)
|
||||
}
|
||||
|
||||
{
|
||||
coaches && (
|
||||
coaches.length > 0 && (
|
||||
<Section id="coaches" title={t('coaches.title')} description={t('coaches.subtitle')}>
|
||||
<div class="grid place-items-center gap-4 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
|
||||
{coaches.map((coach) => (
|
||||
@@ -94,7 +90,12 @@ const t = useTranslations(lang)
|
||||
className="bg-bg-light"
|
||||
>
|
||||
<div class="-ml-4 flex w-[calc(100%+(1rem*2))] md:ml-0 md:w-full">
|
||||
<Swiper className="h-[540px]" client:visible images={optimizedSlider} />
|
||||
<SwiperWithNav
|
||||
className="h-[540px]"
|
||||
slideClass="object-cover sm:object-contain"
|
||||
client:visible
|
||||
images={optimizedSlider}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
|
||||
.swiper-pagination-bullet-active {
|
||||
@apply bg-brand;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||