- one more time rework slider
- rework design system - fill trainer page
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { Image } from 'astro:assets'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
type Props = CollectionEntry<'coaches'> & { lang?: string }
|
||||
|
||||
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={coachPageLink}>
|
||||
<Image
|
||||
src={cover}
|
||||
class="absolute h-full w-full object-cover"
|
||||
alt={coverAlt}
|
||||
width={300}
|
||||
height={390}
|
||||
format="webp"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<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>}
|
||||
<a href={coachPageLink} class="mt-2 text-brand hover:text-brand/80" data-astro-prefetch>
|
||||
{t('coaches.link')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
9
src/components/GymGallery.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MediaSwiperWithNav } from './swiper/MediaSwiperWithNav'
|
||||
|
||||
export type GymGalleryProps = {
|
||||
images: ImportAttributes[]
|
||||
}
|
||||
|
||||
export const GymGallery: React.FC<GymGalleryProps> = ({ images }) => {
|
||||
return <MediaSwiperWithNav images={images} />
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const { title, description, cta, image, imageAlt } = Astro.props
|
||||
className="mt-[var(--header-height)] p-0 md:mt-0 md:flex-col-reverse md:py-24 lg:flex-row lg:items-center"
|
||||
>
|
||||
<div class="px-4 py-8 md:p-0 md:text-center lg:w-1/2 lg:text-start">
|
||||
<h1 class="mb-4 text-4xl font-bold leading-tight md:text-6xl" set:html={title} />
|
||||
<h1 class="mb-4 text-4xl font-bold leading-tight lg:text-6xl" set:html={title} />
|
||||
<p class="text-brand-text mb-6">{description}</p>
|
||||
{
|
||||
cta && (
|
||||
@@ -26,7 +26,7 @@ const { title, description, cta, image, imageAlt } = Astro.props
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="relative hidden md:block md:h-[450px] lg:w-1/2">
|
||||
<div class="relative hidden md:block md:h-[400px] lg:w-1/2">
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
|
||||
@@ -16,7 +16,7 @@ const repeatedTexts =
|
||||
repeatedTexts.map((text) => (
|
||||
<>
|
||||
<h2>{text}</h2>
|
||||
<Image src={wideCrossIconSrc} alt="cross-icon" />
|
||||
<Image src={wideCrossIconSrc} alt="cross-icon" loading="eager" />
|
||||
</>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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 { SwiperSlide } from 'swiper/react'
|
||||
import StarIconSrc from '@/assets/icons/StarIcon.svg'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { SimpleSwiper } from './swiper/SimpleSwiper'
|
||||
|
||||
type Review = z.infer<ReturnType<typeof reviewsSchema>>
|
||||
type Author = Review['author']
|
||||
@@ -19,30 +17,25 @@ type ReviewsProps = {
|
||||
|
||||
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:ml-0 last:mr-0 md:max-w-[600px] md:first:ml-24 md:last:mr-24'
|
||||
const swiperSlideClassName = '!h-auto md:max-w-[600px]'
|
||||
|
||||
export const Reviews: React.FC<ReviewsProps> = ({ reviews, lang }) => {
|
||||
const t = useTranslations(lang)
|
||||
|
||||
return (
|
||||
<Swiper
|
||||
modules={[Scrollbar, Mousewheel]}
|
||||
<SimpleSwiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={24}
|
||||
scrollbar={{
|
||||
hide: true,
|
||||
draggable: true,
|
||||
}}
|
||||
freeMode={false}
|
||||
breakpoints={{
|
||||
768: {
|
||||
slidesPerView: 'auto',
|
||||
freeMode: true,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{reviews.map(({ review, author }, 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="card flex h-full flex-col gap-6 md:px-14 md:py-16">
|
||||
<div className="flex gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<img
|
||||
@@ -77,7 +70,7 @@ export const Reviews: React.FC<ReviewsProps> = ({ reviews, lang }) => {
|
||||
</SwiperSlide>
|
||||
))}
|
||||
<SwiperSlide className={swiperSlideClassName}>
|
||||
<div className="h-full bg-bg-light transition-colors hover:bg-bg-light/80">
|
||||
<div className="card h-full transition-colors hover:bg-bg-light/80">
|
||||
<a
|
||||
href={REVIEWS_LINK}
|
||||
target="_blank"
|
||||
@@ -99,6 +92,6 @@ export const Reviews: React.FC<ReviewsProps> = ({ reviews, lang }) => {
|
||||
</a>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</SimpleSwiper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ type Props = {
|
||||
const { title, description, className, id } = Astro.props
|
||||
---
|
||||
|
||||
<section
|
||||
class={cn('flex flex-col gap-6 py-12 px-4 md:py-24 md:px-16 lg:px-24 md:gap-12', className)}
|
||||
id={id}
|
||||
<section class="w-full relative min-h-fit overflow-hidden" id={id}>
|
||||
<div
|
||||
class={cn(
|
||||
'w-full flex flex-col gap-6 py-12 px-4 md:py-24 md:px-16 lg:px-24 md:gap-12',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{
|
||||
(title || description) && (
|
||||
<div class="flex w-full max-w-[750px] flex-col gap-6 self-center text-center">
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
{title && (
|
||||
<h2 class="text-4xl font-bold leading-tight md:text-5xl">
|
||||
<Fragment set:html={title} />
|
||||
@@ -28,4 +31,5 @@ const { title, description, className, id } = Astro.props
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
import type { servicesSchema } from '@/content.config'
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
type Props = z.infer<typeof servicesSchema> & { highlighted?: boolean }
|
||||
|
||||
const { title, description, charachteristics, highlighted = false, link } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex h-full w-full flex-col border-2 px-5 py-6 md:min-h-[550px] md:max-w-[400px] md:px-7 md:py-8',
|
||||
'border border-transparent bg-card',
|
||||
{ 'border-brand': highlighted },
|
||||
]}
|
||||
>
|
||||
<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">
|
||||
{
|
||||
charachteristics?.map((charachteristic) => (
|
||||
<li class="flex items-start justify-between space-x-2">
|
||||
<span class="text-text-light">{charachteristic.text}</span>
|
||||
{charachteristic.price && (
|
||||
<span class="ml-2 text-nowrap text-end text-brand" set:html={charachteristic.price} />
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
{
|
||||
link && (
|
||||
<a href={link.href} target={link.target} class="brand-btn-outline">
|
||||
{link.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
33
src/components/coaches/CoachCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import rightArrowIconSrc from '@/assets/icons/RightArrowIcon.svg'
|
||||
|
||||
export type CoachCardProps = CollectionEntry<'coaches'> & { lang?: string }
|
||||
|
||||
export const CoachCard: React.FC<CoachCardProps> = ({ data, lang, slug }) => {
|
||||
const { name, description, cover, coverAlt } = data
|
||||
const t = useTranslations(lang)
|
||||
const coachPageLink = `/coaches/${slug}`
|
||||
|
||||
return (
|
||||
<div className="aspect-3/4 relative h-full max-w-[262px] overflow-hidden rounded-3xl text-white md:max-w-[320px] lg:max-w-[400px]">
|
||||
<img
|
||||
{...cover}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-top mix-blend-luminosity"
|
||||
alt={coverAlt}
|
||||
/>
|
||||
<a
|
||||
href={coachPageLink}
|
||||
data-astro-prefetch
|
||||
className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/80 via-transparent p-2 lg:p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xl font-bold uppercase text-text-light">{name}</h3>
|
||||
<img {...rightArrowIconSrc} width={50} height={50} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/components/coaches/Coaches.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CollectionEntry, z } from 'astro:content'
|
||||
import { SimpleSwiper } from '../swiper/SimpleSwiper'
|
||||
import { SwiperSlide } from 'swiper/react'
|
||||
import { CoachCard } from './CoachCard'
|
||||
|
||||
type CoachesProps = {
|
||||
coaches: CollectionEntry<'coaches'>[]
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export const Coaches: React.FC<CoachesProps> = ({ coaches, lang }) => {
|
||||
return (
|
||||
<SimpleSwiper>
|
||||
{coaches.map((coach, index) => (
|
||||
<SwiperSlide className="!h-auto" style={{ width: 'auto' }} key={index}>
|
||||
<CoachCard {...coach} lang={lang} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</SimpleSwiper>
|
||||
)
|
||||
}
|
||||
@@ -3,40 +3,42 @@ import type { TranslationKey } from '@/i18n/ui'
|
||||
import MobileNavigation from './MobileNavigation'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
|
||||
const NAV_ITEMS: Array<{ key: TranslationKey; href: string }> = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
export type HeaderProps = {
|
||||
navItems?: Array<{ key: TranslationKey; href: string }>
|
||||
lang?: string
|
||||
}
|
||||
|
||||
type Props = HeaderProps
|
||||
|
||||
const { lang, navItems } = Astro.props
|
||||
const { lang } = 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="/" />
|
||||
{
|
||||
navItems && (
|
||||
<>
|
||||
<nav class="hidden md:block">
|
||||
<ul class="grid grid-flow-col gap-6">
|
||||
{navItems.map((item) => (
|
||||
{
|
||||
NAV_ITEMS.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{t(item.key)}</a>
|
||||
</li>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<MobileNavigation navItems={navItems} client:visible />
|
||||
</>
|
||||
)
|
||||
}
|
||||
<MobileNavigation navItems={NAV_ITEMS} client:visible />
|
||||
</header>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -66,16 +66,20 @@ const MobileNavigation: React.FC<MobileNavigationProps> = ({ navItems }) => {
|
||||
<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'
|
||||
'fixed left-0 top-0 z-50 w-full h-screen transform bg-bg-main p-8 pt-16 transition-transform duration-500 ease-in-out',
|
||||
isOpen ? '-translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-col items-center space-y-8">
|
||||
<nav className="flex h-full flex-col justify-between">
|
||||
{navItems.map((item) => (
|
||||
<a key={item.key} href={item.href} className="text-3xl" onClick={toggleMenu}>
|
||||
{t(item.key as any)}
|
||||
</a>
|
||||
))}
|
||||
<hr />
|
||||
<a href="/#services" className="brand-btn w-full" onClick={toggleMenu}>
|
||||
{t('hero.cta.getStarted')}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
34
src/components/services/CharachteristicCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
type Props = {
|
||||
title: string
|
||||
description?: string
|
||||
price?: string
|
||||
link: {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const CharachteristicCard: React.FC<Props> = ({ title, description, price, link }) => {
|
||||
return (
|
||||
<div className="card flex h-full w-full flex-col justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4>{title}</h4>
|
||||
{description && <span>{description}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{price && (
|
||||
<span
|
||||
className="text-nowrap text-4xl font-bold text-brand"
|
||||
dangerouslySetInnerHTML={{ __html: price }}
|
||||
/>
|
||||
)}
|
||||
{link && (
|
||||
<a href={link.href} target={link.target} className="brand-btn-outline">
|
||||
{link.title}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/services/Services.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { servicesSchema } from '@/content.config'
|
||||
import type { z } from 'astro:content'
|
||||
import { useState } from 'react'
|
||||
import { SwiperSlide } from 'swiper/react'
|
||||
import { CharachteristicCard } from './CharachteristicCard'
|
||||
import { cn } from '@/libs/cn'
|
||||
import { SimpleSwiper } from '../swiper/SimpleSwiper'
|
||||
|
||||
export type ServicesProps = {
|
||||
services: z.infer<typeof servicesSchema>[]
|
||||
}
|
||||
|
||||
export const Services: React.FC<ServicesProps> = ({ services }) => {
|
||||
const [serviceId, setServiceId] = useState(0)
|
||||
const selectedService = services[serviceId]
|
||||
const isSelected = (index: number) => index === serviceId
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<SimpleSwiper spaceBetween={8}>
|
||||
{services.map((service, index) => (
|
||||
<SwiperSlide style={{ width: 'auto' }} key={index}>
|
||||
<button
|
||||
className={cn('tag-btn', isSelected(index) && 'tag-btn-active')}
|
||||
onClick={() => setServiceId(index)}
|
||||
>
|
||||
{service.title}
|
||||
</button>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</SimpleSwiper>
|
||||
<SimpleSwiper>
|
||||
{selectedService.charachteristics?.map((charachteristic, index) => (
|
||||
<SwiperSlide
|
||||
className="aspect-3/4 !h-auto w-full max-w-[262px] md:max-w-[320px]"
|
||||
key={index}
|
||||
>
|
||||
<CharachteristicCard
|
||||
{...charachteristic}
|
||||
title={charachteristic.text}
|
||||
description={selectedService.description}
|
||||
link={selectedService.link}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</SimpleSwiper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/components/swiper/MediaSwiper.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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 MediaSwiperProps = SwiperProps & {
|
||||
images: ImportAttributes[]
|
||||
imageClassName?: string
|
||||
}
|
||||
|
||||
export const MediaSwiper = React.forwardRef<SwiperRef, MediaSwiperProps>(
|
||||
({ images, className, modules = [], slideClass, imageClassName, ...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} className={slideClass}>
|
||||
<img
|
||||
{...image}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchPriority={index === 0 ? 'high' : 'auto'}
|
||||
className={cn(
|
||||
'aspect-video h-full w-full rounded-3xl object-cover object-center',
|
||||
imageClassName
|
||||
)}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
)
|
||||
}
|
||||
)
|
||||
MediaSwiper.displayName = 'SimpleSwiper'
|
||||
@@ -5,16 +5,16 @@ import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import { NextButton, PrevButton } from './NavButtons'
|
||||
import { useNavHandlers } from './hooks'
|
||||
import { SimpleSwiper, type SimpleSwiperProps } from './SimpleSwiper'
|
||||
import { MediaSwiper, type MediaSwiperProps } from './MediaSwiper'
|
||||
|
||||
export const SwiperWithNav: React.FC<SimpleSwiperProps> = (props) => {
|
||||
export const MediaSwiperWithNav: React.FC<MediaSwiperProps> = (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} />
|
||||
<MediaSwiper {...props} ref={swiperRef} />
|
||||
<NextButton className="hidden md:block" onClick={handleNext} />
|
||||
</div>
|
||||
)
|
||||
@@ -6,35 +6,34 @@ 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'
|
||||
import { MediaSwiper, type MediaSwiperProps } from './MediaSwiper'
|
||||
|
||||
type SwiperProps = {
|
||||
images: ImportAttributes[]
|
||||
type MediaSwiperPropsWithThumbs = MediaSwiperProps & {
|
||||
thumbs: ImportAttributes[]
|
||||
swiperClassName?: string
|
||||
}
|
||||
|
||||
export const SwiperWithThumbs: React.FC<SwiperProps> = ({ images, thumbs, swiperClassName }) => {
|
||||
export const MediaSwiperWithThumbs: React.FC<MediaSwiperPropsWithThumbs> = ({
|
||||
thumbs,
|
||||
...props
|
||||
}) => {
|
||||
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
|
||||
<div className="grid w-full grid-cols-1 items-center gap-x-10 gap-y-3 lg:grid-cols-[auto_1fr_auto]">
|
||||
<PrevButton className="hidden lg:block" onClick={handlePrev} />
|
||||
<MediaSwiper
|
||||
{...props}
|
||||
modules={[Thumbs]}
|
||||
thumbs={{ swiper: thumbsSwiper }}
|
||||
watchSlidesProgress={true}
|
||||
className={cn(swiperClassName)}
|
||||
images={images}
|
||||
ref={swiperRef}
|
||||
pagination={false}
|
||||
/>
|
||||
<NextButton className="hidden md:block" onClick={handleNext} />
|
||||
<NextButton className="hidden lg:block" onClick={handleNext} />
|
||||
<Swiper
|
||||
onSwiper={setThumbsSwiper}
|
||||
spaceBetween={12}
|
||||
@@ -43,7 +42,7 @@ export const SwiperWithThumbs: React.FC<SwiperProps> = ({ images, thumbs, swiper
|
||||
watchSlidesProgress={true}
|
||||
modules={[Navigation, Thumbs, FreeMode]}
|
||||
// !!! TODO FIXME тут хардкод высоты так как если ставить в процентах, то появляется много пустого места снизу
|
||||
className="relative h-24 w-full justify-center place-self-start md:col-start-2"
|
||||
className="relative h-24 w-full justify-center place-self-start lg:col-start-2"
|
||||
>
|
||||
{thumbs.map((image, index) => (
|
||||
<SwiperSlide
|
||||
@@ -54,7 +53,7 @@ export const SwiperWithThumbs: React.FC<SwiperProps> = ({ images, thumbs, swiper
|
||||
{...image}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
className="h-full w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
@@ -1,43 +1,21 @@
|
||||
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 { FreeMode, Mousewheel } from 'swiper/modules'
|
||||
import { Swiper, type SwiperProps } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/free-mode'
|
||||
import './simple-swiper.css'
|
||||
import { cn } from '@/libs/cn'
|
||||
|
||||
export type SimpleSwiperProps = SwiperProps & {
|
||||
images: ImportAttributes[]
|
||||
}
|
||||
|
||||
export const SimpleSwiper = React.forwardRef<SwiperRef, SimpleSwiperProps>(
|
||||
({ images, className, modules = [], slideClass, ...otherProps }, ref) => {
|
||||
return (
|
||||
export const SimpleSwiper: React.FC<SwiperProps> = ({ children, className, ...props }) => (
|
||||
<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}
|
||||
direction="horizontal"
|
||||
slidesPerView="auto"
|
||||
freeMode={true}
|
||||
spaceBetween={24}
|
||||
mousewheel={{ enabled: true, forceToAxis: true }}
|
||||
modules={[FreeMode, Mousewheel]}
|
||||
{...props}
|
||||
className={cn('simple-swiper', className)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{children}
|
||||
</Swiper>
|
||||
)
|
||||
}
|
||||
)
|
||||
SimpleSwiper.displayName = 'SimpleSwiper'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './SwiperWithNav'
|
||||
export * from './MediaSwiperWithNav'
|
||||
|
||||
4
src/components/swiper/simple-swiper.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.simple-swiper {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -20,4 +20,4 @@ export const TW_SCREENS = {
|
||||
XL: 720,
|
||||
_2XL: 864,
|
||||
},
|
||||
} as const;
|
||||
} as const
|
||||
|
||||
54
src/content/coaches/anna-suleimanova.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
cover: './assets/anna-suleimanova-cover.jpeg'
|
||||
coverAlt: 'anna-suleimanova'
|
||||
name: 'Александра Сулейманова'
|
||||
description: 'Спортсмен и тренер. Опыт работы в фитнес индустрии 9 лет'
|
||||
gallery:
|
||||
- './assets/anna-suleimanova-slide-1.jpeg'
|
||||
- './assets/anna-suleimanova-slide-2.jpeg'
|
||||
- './assets/anna-suleimanova-slide-3.jpeg'
|
||||
socialMediaLinks:
|
||||
- icon: 'instagram'
|
||||
href: 'https://www.instagram.com/kulikovaasasha?igsh=MjZuOXc4OTN3cGh4'
|
||||
target: '_blank'
|
||||
# - icon: 'telegram'
|
||||
# href: 'nks://telegram.me/anna_suleimanova'
|
||||
# target: '_blank'
|
||||
---
|
||||
|
||||
<div class="coach-grid">
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Спортивные достижения
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🏆</span> Чемпионка регионального турнира в категории "Wellness"</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> Призер всероссийский соревнований</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> КМС по плаванию</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> КМС по пауэрлифтингу</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Профессиональный опыт
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Спортсмен и тренер</li>
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Опыт работы в фитнес индустрии 9 лет</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Образование
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🎓</span> ЧГАФКиС «Физкультурно-оздоровительные технологии»</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Персональный тренер и инструктор тренажерного зала. Обучающий центр бодибилдинга и фитнеса. Пермь 2019г.</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Основы подготовки к соревнованиям в номинации «Фитнес-бикини»</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Функциональный тренинг. Обучающий центр бодибилдинга и фитнеса.</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Диетология и нутрициология.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
52
src/content/coaches/anna.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
cover: './assets/anna-cover.jpeg'
|
||||
coverAlt: 'anna'
|
||||
name: 'Анна'
|
||||
description: 'Спортсмен и тренер. Опыт работы в фитнес индустрии более 10 лет'
|
||||
gallery:
|
||||
- './assets/anna-slide-1.jpeg'
|
||||
- './assets/anna-slide-2.jpeg'
|
||||
socialMediaLinks:
|
||||
- icon: 'instagram'
|
||||
href: 'https://www.instagram.com/dmitrienko_a_fit_4you?igsh=MXBpZTVlNHkxOWl6dA=='
|
||||
target: '_blank'
|
||||
# - icon: 'telegram'
|
||||
# href: 'nks://telegram.me/anna_suleimanova'
|
||||
# target: '_blank'
|
||||
---
|
||||
|
||||
<div class="coach-grid">
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Спортивные достижения
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🏆</span> Топ 5 Владимировкой области Фитнес Бикини</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> 4 место в Открытом Кубке Смоленской области</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> Входила в состав по легкой атлетике</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Профессиональный опыт
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Персональный тренер и спортсмен</li>
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Опыт работы в фитнес индустрии более 10 лет</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Образование
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🎓</span> СГУ. Институт Физкультуры и Спорта. Г. Саратов</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> АНО ДПО «УрИПКиП». Современные физкультурно-оздоровительные технологии в физическом воспитании</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> АНО ДПО «УрИПКиП». Детский фитнес в работе с детьми старшего дошкольного и младшего школьного возраста</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Персональный тренер 1,2,3 ступень</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Нутрициолог/Диетолог</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
BIN
src/content/coaches/assets/anna-cover.jpeg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/content/coaches/assets/anna-slide-1.jpeg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/content/coaches/assets/anna-slide-2.jpeg
Normal file
|
After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
BIN
src/content/coaches/assets/anna-suleimanova-slide-1.jpeg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
src/content/coaches/assets/anna-suleimanova-slide-2.jpeg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
src/content/coaches/assets/anna-suleimanova-slide-3.jpeg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
src/content/coaches/assets/batyr-slide-1.JPG
Normal file
|
After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 86 KiB |
@@ -4,36 +4,65 @@ 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'
|
||||
- './assets/batyr-slide-1.JPG'
|
||||
socialMediaLinks:
|
||||
- icon: 'instagram'
|
||||
href: 'https://www.instagram.com/therealbatyr?igsh=MXZkMHdsMDgxM3c4bA%3D%3D&utm_source=qr'
|
||||
target: '_blank'
|
||||
- icon: 'telegram'
|
||||
href: 'nks://telegram.me/therealbatyr2'
|
||||
target: '_blank'
|
||||
---
|
||||
🏆ЧЕМПИОН DUDUSHKIN FF 2022
|
||||
|
||||
🏆ЧЕМПИОН NPC REGIONAL CUP MOSCOW 2024
|
||||
<div class="coach-grid">
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Спортивные достижения
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🥇</span> ЧЕМПИОН DUDUSHKIN FF 2022</li>
|
||||
<li><span class="coach-card-emoji">🥇</span> ЧЕМПИОН NPC REGIONAL CUP MOSCOW 2024</li>
|
||||
<li><span class="coach-card-emoji">🥇</span> ЧЕМПИОН OLYMPIA AMATEUR 2024 SOUTH KOREA</li>
|
||||
<li><span class="coach-card-emoji">🥈</span> ВИЦЕ-ЧЕМПИОН SPS 2022</li>
|
||||
<li><span class="coach-card-emoji">🥈</span> ВИЦЕ-ЧЕМПИОН OKSANA GRISHINA CLASSIC 2023</li>
|
||||
<li><span class="coach-card-emoji">🥈</span> ВИЦЕ-ЧЕМПИОН MUSCLE MULISHA GRANDPRIX 2023</li>
|
||||
<li><span class="coach-card-emoji">🏅</span> TOP-5 DUBAI PRO 2024</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
🏆ЧЕМПИОН OLYMPIA AMATEUR 2024 SOUTH KOREA
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Профессиональный опыт
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Профессиональный выступающий спортсмен и тренер.</li>
|
||||
<li><span class="coach-card-emoji">👨🏫</span> Опыт работы в фитнес индустрии более 12 лет.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН SPS 2022
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Образование
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🎓</span> Школа бодибилдинга и фитнеса им. Б. Вейдера 2014г.</li>
|
||||
<li><span class="coach-card-emoji">🎓</span> Ассоциация профессионалов фитнеса (FPA) 2015г.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН OKSANA GRISHINA CLASSIC 2023
|
||||
|
||||
🏆ВИЦЕ-ЧЕМПИОН
|
||||
MUSCLE MULISHA GRANDPRIX 2023
|
||||
|
||||
🏆TOP-5 DUBAI PRO 2024
|
||||
|
||||
Профессиональный выступающий спортсмен и тренер.
|
||||
Опыт работы в фитнес индустрии более 12 лет.
|
||||
|
||||
Образование:
|
||||
1. Школа бодибилдинга и фитнеса им. Б. Вейдера 2014г.
|
||||
2. Ассоциация профессионалов фитнеса (FPA) 2015г.
|
||||
|
||||
Специализация:
|
||||
Строительство тела (набор мышечной массы, сжигание подкожно-жировой клетчатки без вреда для организма), составление плана и методики тренировочного процесса, разработка и контроль рациона питания (БЖУ индивидуально под каждого подопечного).
|
||||
Спортивное питание. Контроль анализов. Рекомендации по приему БАДов и витаминов.
|
||||
Подготовка к соревнованиям по бодибилдингу. Опыт в подготовке бойцов ММА (весогонка, «витаминная» поддержка).
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">
|
||||
Специализация
|
||||
</h2>
|
||||
<ul class="coach-card-list">
|
||||
<li><span class="coach-card-emoji">🏋️</span> Строительство тела (набор мышечной массы, сжигание подкожно-жировой клетчатки без вреда для организма)</li>
|
||||
<li><span class="coach-card-emoji">📋</span> Составление плана и методики тренировочного процесса</li>
|
||||
<li><span class="coach-card-emoji">🥗</span> Разработка и контроль рациона питания (БЖУ индивидуально под каждого подопечного)</li>
|
||||
<li><span class="coach-card-emoji">🥤</span> Спортивное питание</li>
|
||||
<li><span class="coach-card-emoji">🔬</span> Контроль анализов</li>
|
||||
<li><span class="coach-card-emoji">💊</span> Рекомендации по приему БАДов и витаминов</li>
|
||||
<li><span class="coach-card-emoji">🏆</span> Подготовка к соревнованиям по бодибилдингу</li>
|
||||
<li><span class="coach-card-emoji">🥊</span> Опыт в подготовке бойцов ММА (весогонка, «витаминная» поддержка)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ slider:
|
||||
- image: './assets/slider/IMG_6520.jpg'
|
||||
imageAlt: 'seventeenth-slide'
|
||||
services:
|
||||
- title: 'Разовые Персональные тренировки с тренером'
|
||||
- title: 'Тренировка с тренером'
|
||||
link:
|
||||
title: 'Записаться'
|
||||
href: 'https://wa.me/message/JKEYZFZBLQBIF1'
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ui = {
|
||||
// Footer
|
||||
'footer.logo': 'THEREAL<span class="text-brand">SKILL</span>',
|
||||
'footer.copyright': 'Все права защищены. Политика конфиденциальности.',
|
||||
'coaches.socialMedias.title': (name: string) => `${name} в соцсетях`,
|
||||
},
|
||||
en: {
|
||||
'seo.meta.description':
|
||||
@@ -86,5 +87,6 @@ export const ui = {
|
||||
// Footer
|
||||
'footer.logo': 'THEREAL<span class="text-brand">SKILL</span>',
|
||||
'footer.copyright': 'All rights reserved. Privacy Policy.',
|
||||
'coaches.socialMedias.title': (name: string) => `${name} in social medias`,
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Primitive } from 'astro:schema'
|
||||
import { ui, type TranslationKey } from './ui'
|
||||
|
||||
import { i18n } from 'astro:config/client'
|
||||
@@ -11,7 +12,12 @@ export function getLangFromUrl(url: URL) {
|
||||
}
|
||||
|
||||
export function useTranslations(lang?: string) {
|
||||
return function t(key: TranslationKey) {
|
||||
return ui[lang as keyof typeof ui]?.[key] || ui[i18n!.defaultLocale as keyof typeof ui][key]
|
||||
return function t(key: TranslationKey, payload?: Primitive): string {
|
||||
const translation =
|
||||
ui[lang as keyof typeof ui]?.[key] || ui[i18n!.defaultLocale as keyof typeof ui][key]
|
||||
|
||||
if (typeof translation === 'function') return translation(String(payload))
|
||||
|
||||
return translation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { cn } from '@/libs/cn'
|
||||
import '@/styles/global.css'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
import { ClientRouter, slide } from 'astro:transitions'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
@@ -14,7 +14,7 @@ const t = useTranslations(Astro.currentLocale)
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={Astro.currentLocale}>
|
||||
<html lang={Astro.currentLocale} transition:animate={slide({ duration: '0.3s' })}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
@@ -3,8 +3,12 @@ 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 { MediaSwiperWithThumbs } from '@/components/swiper/MediaSwiperWithThumbs'
|
||||
import Header from '@/components/header/Header.astro'
|
||||
import './styles.css'
|
||||
import { SocialMediaIcon } from '@/components/SocialMediaIcon'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import {slide, ViewTransitions} from 'astro:transitions'
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const coaches = await getCollection('coaches')
|
||||
@@ -13,6 +17,7 @@ export const getStaticPaths = async () => {
|
||||
|
||||
const { data, render } = Astro.props
|
||||
const { gallery, cover } = data
|
||||
const t = useTranslations(Astro.currentLocale)
|
||||
const { Content } = await render()
|
||||
const galleryWithCover = gallery ? [cover, ...gallery] : undefined
|
||||
const [thumbs, optimizedGallery] = galleryWithCover
|
||||
@@ -31,18 +36,37 @@ const [thumbs, optimizedGallery] = galleryWithCover
|
||||
<Header />
|
||||
{
|
||||
optimizedGallery && (
|
||||
<Section>
|
||||
<SwiperWithThumbs
|
||||
swiperClassName="h-[540px]"
|
||||
<Section className="mt-[var(--header-height)] md:mt-0">
|
||||
<div class="grid w-full grid-cols-1 place-items-start gap-8 lg:grid-cols-2">
|
||||
<MediaSwiperWithThumbs
|
||||
client:load
|
||||
images={optimizedGallery}
|
||||
thumbs={thumbs}
|
||||
className="sm:max-w-[400px] lg:max-w-none"
|
||||
imageClassName="aspect-3/4 w-[400px] lg:w-auto object-top mx-auto h-auto"
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-8">
|
||||
<h1 class="text-nowrap font-bold">{data.name}</h1>
|
||||
{data.description && <span>{data.description}</span>}
|
||||
<Content />
|
||||
{data.socialMediaLinks && (
|
||||
<div class="coach-card">
|
||||
<h2 class="coach-card-title">{t('coaches.socialMedias.title', data.name)}</h2>
|
||||
<div class="flex gap-4">
|
||||
{data.socialMediaLinks.map(({ icon, ...link }) => (
|
||||
<a {...link}>
|
||||
<SocialMediaIcon
|
||||
type={icon}
|
||||
className="fill-text hover:fill-text-light transition-all duration-300"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
<Section>
|
||||
<Content />
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
|
||||
29
src/pages/coaches/styles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.coach-grid {
|
||||
@apply grid grid-cols-1 gap-8;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.coach-grid {
|
||||
@apply grid-cols-[repeat(auto-fit,minmax(400px,1fr))];
|
||||
}
|
||||
}
|
||||
|
||||
.coach-card {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.coach-card-title {
|
||||
@apply mb-6 font-bold;
|
||||
}
|
||||
|
||||
.coach-card-emoji {
|
||||
@apply mx-2 text-2xl;
|
||||
}
|
||||
|
||||
.coach-card-list {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.coach-card-list > li {
|
||||
@apply flex items-center rounded-2xl bg-card px-3 py-2;
|
||||
}
|
||||
@@ -2,31 +2,23 @@
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import Section from '@/components/Section.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'
|
||||
import { getEntry } from 'astro:content'
|
||||
import CoachCard from '@/components/CoachCard.astro'
|
||||
import ServiceCard from '@/components/ServiceCard.astro'
|
||||
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 { imageResultToImageAttributes, optimizeSliderImages } from '@/libs/image'
|
||||
import { getCollection } from 'astro:content'
|
||||
import { Services } from '@/components/services/Services'
|
||||
import { GymGallery } from '@/components/GymGallery'
|
||||
import { Coaches } from '@/components/coaches/Coaches'
|
||||
|
||||
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, coaches] = await Promise.all([getEntry('pages', 'main'), getCollection('coaches')])
|
||||
const { slider, services, hero, marquee, reviews, footer } = page?.data || {}
|
||||
@@ -57,7 +49,7 @@ const t = useTranslations(lang)
|
||||
---
|
||||
|
||||
<BaseLayout title={t('home.title')}>
|
||||
<Header lang={lang} navItems={navItems} />
|
||||
<Header lang={lang} />
|
||||
|
||||
{hero && <Hero {...hero} />}
|
||||
|
||||
@@ -72,11 +64,7 @@ const t = useTranslations(lang)
|
||||
{
|
||||
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) => (
|
||||
<CoachCard {...coach} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
<Coaches client:load coaches={coaches} lang={lang} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -89,14 +77,7 @@ const t = useTranslations(lang)
|
||||
description={t('slider.subtitle')}
|
||||
className="bg-bg-light"
|
||||
>
|
||||
<div class="-ml-4 flex w-[calc(100%+(1rem*2))] md:ml-0 md:w-full">
|
||||
<SwiperWithNav
|
||||
className="h-[540px]"
|
||||
slideClass="object-cover sm:object-contain"
|
||||
client:visible
|
||||
images={optimizedSlider}
|
||||
/>
|
||||
</div>
|
||||
<GymGallery client:visible images={optimizedSlider} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -104,11 +85,7 @@ const t = useTranslations(lang)
|
||||
{
|
||||
services && (
|
||||
<Section id="services" title={t('services.title')}>
|
||||
<div class="grid place-items-center gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-3">
|
||||
{services.map((service) => (
|
||||
<ServiceCard {...service} />
|
||||
))}
|
||||
</div>
|
||||
<Services client:visible services={services} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -116,9 +93,7 @@ const t = useTranslations(lang)
|
||||
{
|
||||
optimizedReviews && (
|
||||
<Section id="reviews" title={t('reviews.title')}>
|
||||
<div class="-mx-4 flex w-[calc(100%+2rem)] md:-mx-16 md:w-[calc(100%+8rem)] lg:-mx-24 lg:w-[calc(100%+12rem)]">
|
||||
<Reviews client:visible reviews={optimizedReviews} lang={lang} />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
|
||||
.swiper-pagination-bullet-active {
|
||||
@apply bg-brand;
|
||||
}
|
||||
@@ -37,6 +36,25 @@
|
||||
@apply h-full w-full bg-bg-main text-text;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl;
|
||||
}
|
||||
h2 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
h3 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
h4 {
|
||||
@apply text-xl;
|
||||
}
|
||||
h5 {
|
||||
@apply text-lg;
|
||||
}
|
||||
h6 {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -58,4 +76,13 @@
|
||||
.brand-btn-outline {
|
||||
@apply rounded-full border border-brand bg-transparent px-6 py-3 text-center text-text-light transition-colors duration-300 hover:bg-brand;
|
||||
}
|
||||
.tag-btn {
|
||||
@apply text-sm hover:tag-btn-active rounded-full bg-card p-2 transition-colors duration-300;
|
||||
}
|
||||
.tag-btn-active {
|
||||
@apply bg-brand text-text-light;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-3xl bg-card p-4 md:p-6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,19 @@ export default {
|
||||
DEFAULT: 'rgba(28, 28, 30, 1)',
|
||||
},
|
||||
brand: {
|
||||
DEFAULT: 'rgba(136,10,145, 1)',
|
||||
DEFAULT: 'rgba(199, 14, 211, 1)',
|
||||
},
|
||||
text: {
|
||||
light: '#FFFFFF',
|
||||
DEFAULT: '#AEAEAE',
|
||||
DEFAULT: '#C4C4C4',
|
||||
},
|
||||
border: {
|
||||
light: '#D9D9D9',
|
||||
},
|
||||
},
|
||||
aspectRatio: {
|
||||
'3/4': '3 / 4'
|
||||
},
|
||||
keyframes: {
|
||||
marquee: {
|
||||
to: { transform: 'translateX(-50%)' },
|
||||
|
||||