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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}

4
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

17
astro.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind(), react()],
i18n: {
locales: ['ru', 'en'],
defaultLocale: 'ru',
routing: {
prefixDefaultLocale: false,
},
},
});

7458
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "real-skill-landing",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@astrojs/react": "^4.2.7",
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/vite": "^4.1.6",
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"astro": "^5.7.12",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"swiper": "^11.2.6",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11"
}
}

9
public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,4 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" r="25" transform="rotate(180 25 25)" fill="#D9D9D9" fill-opacity="0.05"/>
<path d="M28.1245 16.4064C28.3316 16.4092 28.5301 16.4893 28.6812 16.631C28.8279 16.7791 28.9102 16.9791 28.9102 17.1876C28.9102 17.3961 28.8279 17.5961 28.6812 17.7443L21.4155 25.0001L28.6812 32.256C28.8057 32.4078 28.8694 32.6005 28.8598 32.7966C28.8501 32.9927 28.7679 33.1783 28.629 33.3171C28.4902 33.456 28.3046 33.5382 28.1085 33.5479C27.9124 33.5575 27.7197 33.4938 27.5679 33.3693L19.7554 25.5568C19.6087 25.4086 19.5264 25.2086 19.5264 25.0001C19.5264 24.7916 19.6087 24.5916 19.7554 24.4435L27.5679 16.631C27.7189 16.4893 27.9174 16.4092 28.1245 16.4064Z" fill="#FBFBFB"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@@ -0,0 +1,4 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" r="25" transform="rotate(180 25 25)" fill="#D9D9D9" fill-opacity="0.05"/>
<path d="M21.8755 33.5936C21.6684 33.5908 21.4699 33.5107 21.3188 33.369C21.1721 33.2209 21.0898 33.0209 21.0898 32.8124C21.0898 32.6039 21.1721 32.4039 21.3188 32.2557L28.5845 24.9999L21.3188 17.744C21.1943 17.5922 21.1306 17.3995 21.1402 17.2034C21.1499 17.0073 21.2321 16.8217 21.371 16.6829C21.5098 16.544 21.6954 16.4618 21.8915 16.4521C22.0876 16.4425 22.2803 16.5062 22.4321 16.6307L30.2446 24.4432C30.3913 24.5914 30.4736 24.7914 30.4736 24.9999C30.4736 25.2084 30.3913 25.4084 30.2446 25.5565L22.4321 33.369C22.2811 33.5107 22.0826 33.5908 21.8755 33.5936Z" fill="#FBFBFB"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@@ -0,0 +1,3 @@
<svg width="26" height="24" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0489 0.927052C12.3483 0.00574112 13.6517 0.00573993 13.9511 0.927051L16.1432 7.67376C16.2771 8.08578 16.661 8.36475 17.0943 8.36475H24.1882C25.1569 8.36475 25.5597 9.60436 24.7759 10.1738L19.0369 14.3435C18.6864 14.5981 18.5397 15.0495 18.6736 15.4615L20.8657 22.2082C21.1651 23.1295 20.1106 23.8956 19.3269 23.3262L13.5878 19.1565C13.2373 18.9019 12.7627 18.9019 12.4122 19.1565L6.67312 23.3262C5.88941 23.8956 4.83493 23.1295 5.13428 22.2082L7.32642 15.4615C7.46029 15.0495 7.31363 14.5981 6.96315 14.3435L1.22405 10.1738C0.440337 9.60436 0.843112 8.36475 1.81184 8.36475H8.90575C9.33897 8.36475 9.72293 8.08578 9.8568 7.67376L12.0489 0.927052Z" fill="#FFC54F"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

75
src/content.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import { defineCollection, z, type ImageFunction } from 'astro:content'
export const coachesSchema = (image: ImageFunction) =>
z.object({
name: z.string(),
description: z.string().optional(),
image: image(),
imageAlt: z.string(),
})
export const servicesSchema = z.object({
title: z.string(),
description: z.string(),
price: z.string(),
link: z.object({
title: z.string(),
href: z.string(),
target: z.string().optional(),
}),
charachteristics: z.array(
z.object({
text: z.string(),
price: z.string().optional(),
})
),
})
export const reviewsSchema = (image: ImageFunction) =>
z.object({
review: z.string(),
author: z.object({
name: z.string(),
description: z.string().optional(),
image: image(),
imageAlt: z.string(),
}),
rating: z.number(),
})
const pages = defineCollection({
schema: ({ image }) =>
z.object({
hero: z
.object({
title: z.string(),
description: z.string(),
image: image(),
imageAlt: z.string(),
cta: z
.object({
title: z.string(),
href: z.string(),
target: z.string().optional(),
})
.optional(),
})
.nullable(),
marquee: z.array(z.string()).optional(),
coaches: z.array(coachesSchema(image)).optional(),
reviews: z.array(reviewsSchema(image)).optional(),
slider: z
.array(
z.object({
image: image(),
imageAlt: z.string(),
})
)
.optional(),
services: z.array(servicesSchema).optional(),
}),
})
export const collections = {
pages,
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 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

97
src/content/pages/main.md Normal file
View File

@@ -0,0 +1,97 @@
---
hero:
title: 'ПРИДАЙТЕ ВАШЕМУ ТЕЛУ <br /> <span class="text-brand">ЗДОРОВУЮ</span> <br /> И <span class="text-brand">СТРОЙНУЮ ФОРМУ</span>'
description: 'Преобразите свое тело и разум с помощью наших увлекательных тренировок и персонализированных программ. Присоединяйтесь к нам для достижения ваших фитнес-целей.'
image: './assets/hero.jpeg'
imageAlt: 'hero'
cta:
title: 'Записаться'
href: '#services'
marquee:
- ВОТ И ВСЕ ЕБАТЬ
- ТЫ СЛАБ
slider:
- image: './assets/slider/1.jpeg'
imageAlt: 'first-slide'
- image: './assets/slider/2.jpeg'
imageAlt: 'second-slide'
- image: './assets/slider/3.jpeg'
imageAlt: 'third-slide'
coaches:
- image: './assets/coaches/1.jpg'
imageAlt: 'first-coach'
name: 'Иванов Иван Иванович'
description: 'Тренер'
- image: './assets/coaches/2.jpg'
imageAlt: 'second-coach'
name: 'Петров Петр Петрович'
description: 'Тренер'
- image: './assets/coaches/3.jpg'
imageAlt: 'third-coach'
name: 'Сидоров Сидор Сидорович'
services:
- title: 'Пробный день'
description: 'Идеально подходит для знакомства с нашим залом и услугами'
price: '1500 ₽'
link:
title: 'Начать'
href: '/checkout/day-pass'
charachteristics:
- text: 'Доступ ко всему оборудованию'
price: '500 ₽'
- text: 'Персональный шкафчик и душ'
price: '300 ₽'
- text: 'Базовая оценка физической подготовки'
price: '700 ₽'
- title: 'Месячный абонемент'
description: 'Гибкая программа тренировок на месяц'
price: '5900 ₽'
link:
title: 'Оформить'
href: '/checkout/monthly'
charachteristics:
- text: 'Полный доступ к залу и оборудованию'
price: '2500 ₽'
- text: '2 занятия с сертифицированным тренером'
price: '2000 ₽'
- text: 'Доступ ко всем групповым занятиям'
price: '1400 ₽'
- title: 'Годовой абонемент'
description: 'Лучшее предложение для постоянных клиентов'
price: '49900 ₽'
link:
title: 'Присоединиться'
href: '/checkout/annual'
charachteristics:
- text: 'Доступ ко всем зонам и премиум-оборудованию'
price: '20000 ₽'
- text: 'Ежемесячные персональные тренировки'
price: '18000 ₽'
- text: 'Индивидуальная консультация по питанию'
price: '11900 ₽'
reviews:
- review: 'Место силы. Комфортная студия. Для тренировок есть всё необходимое . Раздевалки уютные , так же всё продумано до мелочей . Можно после тренировки насладиться кофе . По месторасположению очень комфортно , для парковки места есть всегда. Уютно. Спортивно . Красиво . Стильно . Профессионально! От души рекомендую'
author:
name: 'Анна'
image: './assets/reviews/1.png'
imageAlt: 'anna-avatar'
description: 'Знаток города 16 уровня'
rating: 5
- review: 'Прекрасный и замечательный спортивный зал.
Тренеру Батыру отдельная благодарность за заинтересованность к моим результатам и тренировкам.
Помог с питанием, гормонами и физическим состоянием.
Рекомендую.'
author:
name: 'Станислав К.'
image: './assets/reviews/2.png'
imageAlt: 'stanislav-avatar'
description: 'Знаток города 4 уровня'
rating: 5
- review: 'Уютно, красиво и свежо. Интересный зал, хорошие тренеры. Приятно, что большой спектр оказания услуг!!! Пробная тренировка прошла на ура 🙌!!!'
author:
name: 'Тимур П.'
image: './assets/reviews/3.png'
imageAlt: 'timur-avatar'
description: 'Знаток города 5 уровня'
rating: 5
---

72
src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,72 @@
export const languages = {
en: 'English',
ru: 'Русский',
}
export const ui = {
ru: {
'home.title': 'The real skill studio',
// Header
'header.logo': 'THEREAL<span class="text-brand">SKILL</span>',
'nav.coaches': 'Тренера',
'nav.slider': 'Галерея',
'nav.services': 'Услуги',
'nav.reviews': 'Отзывы',
'nav.contact': 'Контакты',
// Hero Section
'hero.cta.getStarted': 'Записаться',
// Ticker Section
'ticker.healthy': 'ВОТ И ВСЕ ЕБАТЬ',
'ticker.shapeBody': 'ТЫ СЛАБ',
// Coaches Section
'coaches.title': 'НАШИ <span class="text-brand">ТРЕНЕРА</span>',
'coaches.subtitle': 'Профессиональные тренеры с многолетним опытом',
// Slider Section
'slider.title':
'ПОСМОТРИТЕ, ГДЕ МЫ <br /> <span class="text-brand">ВЫПОЛНЯЕМ УПРАЖНЕНИЯ</span>',
'slider.subtitle': 'Современное оборудование в просторном зале',
// Services Section
'services.title': 'НАШИ <span class="text-brand">УСЛУГИ</span>',
'services.subtitle': 'Широкий спектр услуг для достижения ваших целей',
// Reviews Section
'reviews.title': 'ЧТО О НАС <span class="text-brand">ГОВОРЯТ</span>',
'reviews.viewMore.title': 'Посмотреть все отзывы',
'reviews.viewMore.description':
'Мы рады видеть, как наши клиенты достигли своих целей и превзошли ожидания.',
// Footer
'footer.logo': 'THEREAL<span class="text-brand">SKILL</span>',
'footer.copyright': 'Все права защищены. Политика конфиденциальности.',
},
en: {
'home.title': 'Home Page',
// Header
'header.logo': 'THEREAL<span class="text-brand">SKILL</span>',
'nav.coaches': 'Coaches',
'nav.slider': 'Gallery',
'nav.services': 'Services',
'nav.reviews': 'Reviews',
'nav.contact': 'Contact',
// Hero Section
'hero.cta.getStarted': 'Get Started',
// Ticker Section
'ticker.healthy': "THAT'S ALL FOLKS",
'ticker.shapeBody': 'YOU ARE WEAK',
// Coaches Section
'coaches.title': 'OUR <span class="text-brand">COACHES</span>',
'coaches.subtitle': 'Professional trainers with years of experience',
// Slider Section
'slider.title': 'TAKE A LOOK WHERE WE <br /> <span class="text-brand">EXERCISE</span>',
'slider.subtitle': 'Modern equipment in a spacious gym',
// Services Section
'services.title': 'OUR <span class="text-brand">SERVICES</span>',
'services.subtitle': 'Wide range of services to achieve your goals',
// Reviews Section
'reviews.title': 'WHAT THEY <span class="text-brand">SAY</span>',
'reviews.viewMore.title': 'View all reviews',
'reviews.viewMore.description':
'We are proud to see how our clients achieved their goals and exceeded expectations.',
// Footer
'footer.logo': 'THEREAL<span class="text-brand">SKILL</span>',
'footer.copyright': 'All rights reserved. Privacy Policy.',
},
} as const

17
src/i18n/utils.ts Normal file
View File

@@ -0,0 +1,17 @@
import { ui } from './ui';
import { i18n } from 'astro:config/client';
export function getLangFromUrl(url: URL) {
const [, lang] = url.pathname.split('/');
if (lang in ui) return lang as keyof typeof ui;
return i18n!.defaultLocale;
}
export function useTranslations(lang?: string) {
return function t(key: keyof (typeof ui)[keyof typeof ui]) {
return ui[lang as keyof typeof ui]?.[key] || ui[i18n!.defaultLocale as keyof typeof ui][key];
};
}

View File

@@ -0,0 +1,23 @@
---
import '@/styles/global.css'
type Props = {
title: string
}
const { title } = Astro.props
---
<!doctype html>
<html lang={Astro.currentLocale}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="flex flex-col">
<slot />
</body>
</html>

6
src/libs/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

149
src/pages/index.astro Normal file
View File

@@ -0,0 +1,149 @@
---
import MobileNavigation from '@/components/MobileNavigation.tsx'
import { useTranslations } from '@/i18n/utils'
import BaseLayout from '../layouts/BaseLayout.astro'
import Section from '@/components/Section.astro'
import { Swiper } from '@/components/swiper'
import type { CollectionEntry } from 'astro:content'
import { getEntry } from 'astro:content'
import CoachCard from '@/components/CoachCard.astro'
import ServiceCard from '@/components/ServiceCard.astro'
import { Image } from 'astro:assets'
import Marquee from '@/components/Marquee.astro'
import { Reviews } from '@/components/Reviews.tsx'
type Props = CollectionEntry<'pages'>['data']
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 } = Astro.params
const page = await getEntry('pages', 'main')
const { slider, coaches, services, hero, marquee, reviews } = page?.data || {}
const t = useTranslations(lang)
---
<BaseLayout title={t('home.title')}>
<header
class="fixed left-0 right-0 top-0 z-50 flex h-[var(--header-height)] items-center justify-between bg-bg-main px-6 md:px-10"
>
<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 />
</header>
{
hero && (
<Section id="hero" className="md:flex-row mt-16 md:mt-0 p-0 md:p-0">
<div class="mb-8 px-6 py-10 md:mb-0 md:w-1/2 md:p-24">
<h1 class="mb-4 text-4xl font-bold leading-tight md:text-6xl" set:html={hero.title} />
<p class="text-brand-text mb-6">{hero.description}</p>
{hero.cta && (
<div class="flex space-x-4">
<a href="#services" class="brand-btn">
{hero.cta?.title}
</a>
</div>
)}
</div>
<div class="relative hidden md:block md:w-1/2">
<Image src={hero.image} alt={hero.imageAlt} class="absolute h-full w-full object-cover" />
</div>
</Section>
)
}
{
marquee && (
<section class="min-h-[105px] overflow-clip bg-brand">
<Marquee texts={marquee} />
</section>
)
}
{
coaches && (
<Section id="coaches" title={t('coaches.title')} description={t('coaches.subtitle')}>
<div class="grid w-full grid-flow-col place-items-center gap-6 overflow-x-scroll md:gap-14">
{coaches.map((coach) => (
<CoachCard {...coach} />
))}
</div>
</Section>
)
}
{
slider && (
<Section
id="image-slider"
title={t('slider.title')}
description={t('slider.subtitle')}
className="bg-bg-light"
>
<Swiper
className="h-[540px]"
client:visible
images={slider.map(({ image, imageAlt }) => ({ ...image, alt: imageAlt }))}
/>
</Section>
)
}
{
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>
</Section>
)
}
{
reviews && (
<Section id="reviews" title={t('reviews.title')}>
<div class="-ml-6 flex w-[calc(100%+(1.5rem*2))] md:-ml-24 md:w-[calc(100%+(6rem*2))]">
<Reviews client:visible reviews={reviews} lang={lang} />
</div>
</Section>
)
}
<Section className="px-0 md:px-0" id="contacts">
<script
type="text/javascript"
charset="utf-8"
async
src="https://api-maps.yandex.ru/services/constructor/1.0/js/?um=constructor%3A069b17526dc83cbf87c488f13ae089eb3af0f8f74af57e1be82e183434693562&width=100%25&height=400&lang=ru_RU&scroll=true"
></script>
</Section>
<footer class="py-8 text-center">
<div class="container mx-auto px-6 md:px-10">
<div class="mb-4 text-2xl font-bold text-text-light" set:html={t('footer.logo')} />
<p class="text-light text-sm">
© {new Date().getFullYear()}
{t('footer.copyright')}
</p>
</div>
</footer>
</BaseLayout>

42
src/styles/global.css Normal file
View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--header-height: 4rem;
}
html {
font-family: system-ui, sans-serif;
scroll-behavior: smooth;
@apply scroll-pt-[var(--header-height)];
}
html,
body {
@apply h-full w-full bg-bg-main text-text;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-text-light;
}
a {
@apply transform cursor-pointer no-underline duration-300 ease-in-out hover:text-text-light;
}
}
@layer components {
.brand-btn {
@apply rounded-full bg-brand px-4 py-2 text-center text-text-light transition-colors duration-300 hover:bg-brand/80;
}
.brand-btn-outline {
@apply rounded-full border border-brand bg-transparent px-4 py-2 text-center text-text-light transition-colors duration-300 hover:bg-brand;
}
}

36
tailwind.config.js Normal file
View File

@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
bg: {
main: 'rgba(19, 21, 20, 1)',
light: 'rgba(28, 28, 30, 1)',
},
card: {
DEFAULT: 'rgba(28, 28, 30, 1)',
},
brand: {
DEFAULT: 'rgba(163, 61, 215, 1)',
},
text: {
light: '#FFFFFF',
DEFAULT: '#AEAEAE',
},
border: {
light: '#D9D9D9',
},
},
keyframes: {
marquee: {
to: { transform: 'translateX(-50%)' },
},
},
animation: {
marquee: 'marquee 20s linear infinite',
},
},
},
plugins: [],
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}