reworked slider + added coaches page

This commit is contained in:
Salam-vsem
2025-05-15 00:59:22 +03:00
parent 1b8cb5dae9
commit 2c1ebe5dae
32 changed files with 389 additions and 201 deletions

View File

@@ -7,6 +7,7 @@ import react from '@astrojs/react'
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [tailwind(), react()], integrations: [tailwind(), react()],
prefetch: true,
i18n: { i18n: {
locales: ['ru', 'en'], locales: ['ru', 'en'],
defaultLocale: 'ru', defaultLocale: 'ru',

View File

@@ -1,21 +1,22 @@
--- ---
import type { coachesSchema } from '@/content.config'
import { useTranslations } from '@/i18n/utils' import { useTranslations } from '@/i18n/utils'
import { Image } from 'astro:assets' 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 t = useTranslations(lang)
const coachPageLink = `/coaches/${slug}`
--- ---
<div class="flex h-full w-full flex-col items-center justify-start gap-5"> <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 <Image
src={image} src={cover}
class="absolute h-full w-full object-cover" class="absolute h-full w-full object-cover"
alt={imageAlt} alt={coverAlt}
width={300} width={300}
height={390} height={390}
format="webp" format="webp"
@@ -26,12 +27,8 @@ const t = useTranslations(lang)
<div class="inline-flex w-full max-w-[300px] flex-col gap-1"> <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> <h3 class="text-xl font-bold uppercase text-text-light">{name}</h3>
{description && <p>{description}</p>} {description && <p>{description}</p>}
{ <a href={coachPageLink} class="mt-2 text-brand hover:text-brand/80" data-astro-prefetch>
link && (
<a href={link} target="_blank" class="mt-2 text-brand hover:text-brand/80">
{t('coaches.link')} {t('coaches.link')}
</a> </a>
)
}
</div> </div>
</div> </div>

View File

@@ -4,20 +4,12 @@ import { Image } from 'astro:assets'
import logoSrc from '@/assets/logo.png' import logoSrc from '@/assets/logo.png'
import type { z } from 'astro:content' import type { z } from 'astro:content'
import type { footerSchema } from '@/content.config' import type { footerSchema } from '@/content.config'
import type React from 'react' import { SocialMediaIcon } from './SocialMediaIcon'
import { TelegramIcon } from '@/assets/icons/TelegramIcon'
import { InstagramIcon } from '@/assets/icons/InstagramIcon'
import type { IconProps } from '@/assets/icons/types'
type Props = z.infer<typeof footerSchema> & { type Props = z.infer<typeof footerSchema> & {
lang?: string lang?: string
} }
const SOCIAL_MEDIA_ICONS_MAP: Record<string, React.ComponentType<IconProps>> = {
telegram: TelegramIcon,
instagram: InstagramIcon,
}
const { lang, socialMediaLinks } = Astro.props const { lang, socialMediaLinks } = Astro.props
const t = useTranslations(lang) const t = useTranslations(lang)
--- ---
@@ -40,11 +32,12 @@ const t = useTranslations(lang)
socialMediaLinks && ( socialMediaLinks && (
<div class="flex gap-4 text-text-light"> <div class="flex gap-4 text-text-light">
{socialMediaLinks.map(({ href, target, icon }) => { {socialMediaLinks.map(({ href, target, icon }) => {
const Icon = SOCIAL_MEDIA_ICONS_MAP[icon]
return ( return (
<a href={href} target={target} rel="noopener noreferrer"> <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> </a>
) )
})} })}

View File

@@ -4,7 +4,7 @@ import type { z } from 'astro:content'
type Props = z.infer<typeof servicesSchema> & { highlighted?: boolean } 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 <div
@@ -14,8 +14,9 @@ const { title, charachteristics, highlighted = false, link } = Astro.props
{ 'border-brand': highlighted }, { 'border-brand': highlighted },
]} ]}
> >
<div class="mb-8"> <div class="mb-8 flex flex-col gap-2">
<h3 class="mb-4 text-2xl font-bold">{title}</h3> <h3 class="text-2xl font-bold">{title}</h3>
{description && <span>{description}</span>}
</div> </div>
<ul class="mb-16 flex-grow space-y-4"> <ul class="mb-16 flex-grow space-y-4">

View 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
}

View File

@@ -1,41 +1,42 @@
--- ---
import type { TranslationKey } from '@/i18n/ui'
import MobileNavigation from './MobileNavigation' import MobileNavigation from './MobileNavigation'
import { useTranslations } from '@/i18n/utils' import { useTranslations } from '@/i18n/utils'
type Props = { export type HeaderProps = {
navItems?: Array<{ key: TranslationKey; href: string }>
lang?: string lang?: string
} }
const { lang } = Astro.props type Props = HeaderProps
const navItems = [ const { lang, navItems } = Astro.props
{ 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 t = useTranslations(lang) const t = useTranslations(lang)
--- ---
<header <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" 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" id="main-header"
> >
<a class="text-2xl font-bold text-text-light" set:html={t('header.logo')} href="#hero" /> <a class="text-2xl font-bold text-text-light" set:html={t('header.logo')} href="/" />
{
navItems && (
<>
<nav class="hidden md:block"> <nav class="hidden md:block">
<ul class="grid grid-flow-col gap-6"> <ul class="grid grid-flow-col gap-6">
{ {navItems.map((item) => (
navItems.map((item) => (
<li> <li>
<a href={item.href}>{t(item.key)}</a> <a href={item.href}>{t(item.key)}</a>
</li> </li>
)) ))}
}
</ul> </ul>
</nav> </nav>
<MobileNavigation navItems={navItems} client:visible /> <MobileNavigation navItems={navItems} client:visible />
</>
)
}
</header> </header>
<script> <script>

View File

@@ -1,10 +1,8 @@
import type { ImageMetadata } from 'astro' import type { ImageMetadata } from 'astro'
import React, { type ButtonHTMLAttributes } from 'react' import React, { type ButtonHTMLAttributes } from 'react'
import { useSwiper } from 'swiper/react'
import ChevronLeft from '@/assets/icons/LeftArrowIcon.svg' import ChevronLeft from '@/assets/icons/LeftArrowIcon.svg'
import ChevronRight from '@/assets/icons/RightArrowIcon.svg' import ChevronRight from '@/assets/icons/RightArrowIcon.svg'
import { cn } from '@/libs/cn' import { cn } from '@/libs/cn'
import { useSlideChange } from './hooks'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { iconSrc: ImageMetadata } type Props = ButtonHTMLAttributes<HTMLButtonElement> & { iconSrc: ImageMetadata }
@@ -13,7 +11,7 @@ export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
<button <button
{...buttonProps} {...buttonProps}
className={cn( 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.disabled && 'border-none',
buttonProps.className buttonProps.className
)} )}
@@ -24,35 +22,9 @@ export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
} }
export const PrevButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => { export const PrevButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
const swiper = useSwiper() return <NavButton {...props} iconSrc={ChevronLeft} />
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) => { export const NextButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
const swiper = useSwiper() return <NavButton {...props} iconSrc={ChevronRight} />
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,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'

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -1,14 +1,16 @@
import { useEffect } from 'react' import { type RefObject, useCallback } from 'react'
import { useSwiper } from 'swiper/react' import type { SwiperRef } from 'swiper/react'
export const useSlideChange = (onUpdate: VoidFunction) => { export const useNavHandlers = (swiperRef: RefObject<SwiperRef | null>) => {
const swiper = useSwiper() const handlePrev = useCallback(() => {
if (!swiperRef.current) return
swiperRef.current.swiper.slidePrev()
}, [])
useEffect(() => { const handleNext = useCallback(() => {
if (!swiper) return if (!swiperRef.current) return
swiperRef.current.swiper.slideNext()
}, [])
swiper.on('slideChange', onUpdate) return { handlePrev, handleNext } as const
return () => swiper.off('slideChange', onUpdate)
}, [swiper])
} }

View File

@@ -1 +1 @@
export * from './Swiper' export * from './SwiperWithNav'

View File

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

View File

@@ -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) => export const heroSchema = (image: ImageFunction) =>
z.object({ z.object({
@@ -15,13 +21,16 @@ export const heroSchema = (image: ImageFunction) =>
.optional(), .optional(),
}) })
export const coachesSchema = (image: ImageFunction) => export const coaches = defineCollection({
schema: ({ image }) =>
z.object({ z.object({
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().optional(),
image: image(), cover: image(),
imageAlt: z.string(), coverAlt: z.string(),
link: z.string().optional(), gallery: z.array(image()).optional(),
socialMediaLinks: z.array(socialMediaLinkSchema).optional(),
}),
}) })
export const servicesSchema = z.object({ export const servicesSchema = z.object({
@@ -57,15 +66,7 @@ export const reviewsSchema = (image: ImageFunction) =>
export const footerSchema = z.object({ export const footerSchema = z.object({
copyright: z.string().optional(), copyright: z.string().optional(),
socialMediaLinks: z socialMediaLinks: z.array(socialMediaLinkSchema).optional(),
.array(
z.object({
icon: z.string(),
href: z.string(),
target: z.string().optional(),
})
)
.optional(),
}) })
const pages = defineCollection({ const pages = defineCollection({
@@ -73,7 +74,6 @@ const pages = defineCollection({
z.object({ z.object({
hero: heroSchema(image).optional(), hero: heroSchema(image).optional(),
marquee: z.array(z.string()).optional(), marquee: z.array(z.string()).optional(),
coaches: z.array(coachesSchema(image)).optional(),
reviews: z.array(reviewsSchema(image)).optional(), reviews: z.array(reviewsSchema(image)).optional(),
slider: z slider: z
.array( .array(
@@ -90,4 +90,5 @@ const pages = defineCollection({
export const collections = { export const collections = {
pages, pages,
coaches,
} }

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View 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г.
Специализация:
Строительство тела (набор мышечной массы, сжигание подкожно-жировой клетчатки без вреда для организма), составление плана и методики тренировочного процесса, разработка и контроль рациона питания (БЖУ индивидуально под каждого подопечного).
Спортивное питание. Контроль анализов. Рекомендации по приему БАДов и витаминов.
Подготовка к соревнованиям по бодибилдингу. Опыт в подготовке бойцов ММА (весогонка, «витаминная» поддержка).

View File

@@ -8,11 +8,7 @@ hero:
title: 'Записаться' title: 'Записаться'
href: '#services' href: '#services'
marquee: marquee:
- ДОСТИГАЙТЕ ЦЕЛЕЙ - Ваш идеальный результат — в шаге от дома!
- БУДЬТЕ СИЛЬНЕЕ
- ТРЕНИРУЙТЕСЬ С НАМИ
- МЕНЯЙТЕСЬ К ЛУЧШЕМУ
- СТАНОВИТЕСЬ ЗДОРОВЕЕ
slider: slider:
- image: './assets/slider/IMG_6491.jpg' - image: './assets/slider/IMG_6491.jpg'
imageAlt: 'first-slide' imageAlt: 'first-slide'
@@ -48,17 +44,6 @@ slider:
imageAlt: 'sixteenth-slide' imageAlt: 'sixteenth-slide'
- image: './assets/slider/IMG_6520.jpg' - image: './assets/slider/IMG_6520.jpg'
imageAlt: 'seventeenth-slide' 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: services:
- title: 'Разовые Персональные тренировки с тренером' - title: 'Разовые Персональные тренировки с тренером'
link: link:
@@ -82,7 +67,7 @@ services:
price: '60 000 ₽' price: '60 000 ₽'
- text: 'Блок из 15 тренировок СПЛИТ' - text: 'Блок из 15 тренировок СПЛИТ'
price: '110 000 ₽' price: '110 000 ₽'
- title: 'Безлимитные Абонементы на месяц' - title: 'Безлимитные Абонементы'
description: '30 календарных дней занятий с тренером' description: '30 календарных дней занятий с тренером'
link: link:
title: 'Записаться' title: 'Записаться'

View File

@@ -3,6 +3,8 @@ export const languages = {
ru: 'Русский', ru: 'Русский',
} }
export type TranslationKey = keyof (typeof ui)[keyof typeof ui]
export const ui = { export const ui = {
ru: { ru: {
'seo.meta.description': 'seo.meta.description':

View File

@@ -1,4 +1,4 @@
import { ui } from './ui' import { ui, type TranslationKey } from './ui'
import { i18n } from 'astro:config/client' import { i18n } from 'astro:config/client'
@@ -11,7 +11,7 @@ export function getLangFromUrl(url: URL) {
} }
export function useTranslations(lang?: string) { 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] return ui[lang as keyof typeof ui]?.[key] || ui[i18n!.defaultLocale as keyof typeof ui][key]
} }
} }

View File

@@ -1,12 +1,15 @@
--- ---
import { useTranslations } from '@/i18n/utils' import { useTranslations } from '@/i18n/utils'
import { cn } from '@/libs/cn'
import '@/styles/global.css' import '@/styles/global.css'
import { ClientRouter } from 'astro:transitions'
type Props = { type Props = {
title: string title: string
bodyClass?: string
} }
const { title } = Astro.props const { title, bodyClass } = Astro.props
const t = useTranslations(Astro.currentLocale) const t = useTranslations(Astro.currentLocale)
--- ---
@@ -26,8 +29,9 @@ const t = useTranslations(Astro.currentLocale)
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href={Astro.url} /> <link rel="canonical" href={Astro.url} />
<title>{`${title} | ${t('seo.og.title')}`}</title> <title>{`${title} | ${t('seo.og.title')}`}</title>
<ClientRouter />
</head> </head>
<body class="flex flex-col"> <body class={cn('flex flex-col', bodyClass)}>
<slot /> <slot />
</body> </body>
</html> </html>

View File

@@ -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 => ({ export const imageResultToImageAttributes = (imageResult: GetImageResult): ImportAttributes => ({
src: imageResult.src, src: imageResult.src,
srcSet: imageResult.srcSet?.attribute, srcSet: imageResult.srcSet?.attribute,
...imageResult.attributes, ...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)
)
)

View 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>

View File

@@ -2,8 +2,8 @@
import { useTranslations } from '@/i18n/utils' import { useTranslations } from '@/i18n/utils'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import Section from '@/components/Section.astro' import Section from '@/components/Section.astro'
import { Swiper } from '@/components/swiper' import { SwiperWithNav } from '@/components/swiper'
import Header from '@/components/header/Header.astro' import Header, { type HeaderProps } from '@/components/header/Header.astro'
import Footer from '@/components/Footer.astro' import Footer from '@/components/Footer.astro'
import type { CollectionEntry } from 'astro:content' 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 { Reviews, type OptimizedReview } from '@/components/Reviews.tsx'
import Hero from '@/components/Hero.astro' import Hero from '@/components/Hero.astro'
import { getImage } from 'astro:assets' import { getImage } from 'astro:assets'
import { TW_SCREENS, TW_WIDTHS } from '@/constants/images' import { imageResultToImageAttributes, optimizeSliderImages } from '@/libs/image'
import { imageResultToImageAttributes } from '@/libs/image' import { getCollection } from 'astro:content'
type Props = CollectionEntry<'pages'>['data'] 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 { lang } = Astro.params
const page = await getEntry('pages', 'main') const [page, coaches] = await Promise.all([getEntry('pages', 'main'), getCollection('coaches')])
const { slider, coaches, services, hero, marquee, reviews, footer } = page?.data || {} const { slider, services, hero, marquee, reviews, footer } = page?.data || {}
const optimizedSlider = slider const optimizedSlider = slider
? await Promise.all( ? await optimizeSliderImages(slider.map(({ image, imageAlt }) => ({ src: image, alt: imageAlt })))
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)
)
)
: undefined : undefined
const optimizedReviews = reviews const optimizedReviews = reviews
@@ -61,7 +57,7 @@ const t = useTranslations(lang)
--- ---
<BaseLayout title={t('home.title')}> <BaseLayout title={t('home.title')}>
<Header lang={lang} /> <Header lang={lang} navItems={navItems} />
{hero && <Hero {...hero} />} {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')}> <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"> <div class="grid place-items-center gap-4 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{coaches.map((coach) => ( {coaches.map((coach) => (
@@ -94,7 +90,12 @@ const t = useTranslations(lang)
className="bg-bg-light" className="bg-bg-light"
> >
<div class="-ml-4 flex w-[calc(100%+(1rem*2))] md:ml-0 md:w-full"> <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> </div>
</Section> </Section>
) )

View File

@@ -21,6 +21,10 @@
@layer base { @layer base {
:root { :root {
--header-height: 4rem; --header-height: 4rem;
.swiper-pagination-bullet-active {
@apply bg-brand;
}
} }
html { html {