init
24
.gitignore
vendored
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal 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
@@ -0,0 +1,47 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](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
@@ -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
31
package.json
Normal 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
@@ -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 |
4
src/assets/icons/LeftArrowIcon.svg
Normal 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 |
4
src/assets/icons/RightArrowIcon.svg
Normal 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 |
3
src/assets/icons/StarIcon.svg
Normal 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 |
22
src/components/CoachCard.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
type Props = {
|
||||
name: string
|
||||
description?: string
|
||||
image: ImageMetadata
|
||||
imageAlt: string
|
||||
}
|
||||
|
||||
const { name, description, image, imageAlt } = Astro.props
|
||||
---
|
||||
|
||||
<div class="flex h-full w-full flex-col items-center justify-start gap-4">
|
||||
<div class="relative h-[390px] w-[300px]">
|
||||
<Image src={image} class="absolute h-full w-full object-cover" alt={imageAlt} />
|
||||
</div>
|
||||
<div class="inline-flex w-full max-w-[300px] flex-col gap-1">
|
||||
<h3 class="text-xl font-bold uppercase text-text-light">{name}</h3>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
12
src/components/Marquee.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
type Props = {
|
||||
texts: string[]
|
||||
}
|
||||
|
||||
const { texts } = Astro.props
|
||||
const repeatedTexts = texts.length < 10 ? Array.from({ length: 10 / texts.length }, () => texts).flat() : texts;
|
||||
---
|
||||
|
||||
<div class="flex h-full w-max animate-marquee items-center gap-10 text-5xl font-bold uppercase">
|
||||
{repeatedTexts.map((text) => <h2>{text}</h2>)}
|
||||
</div>
|
||||
85
src/components/MobileNavigation.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
import { cn } from '@/libs/cn'
|
||||
import React, { useState, useEffect, type ButtonHTMLAttributes } from 'react'
|
||||
|
||||
const t = useTranslations()
|
||||
|
||||
type AnimatedBurgerButtonProps = ButtonHTMLAttributes<unknown> & {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const AnimatedBurgerButton: React.FC<AnimatedBurgerButtonProps> = ({ isOpen, ...buttonProps }) => (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className="fixed right-4 top-4 z-[60] flex h-8 w-8 cursor-pointer flex-col justify-around border-none bg-transparent p-0 focus:outline-none"
|
||||
aria-label={isOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'translate-y-[11px] rotate-45' : ''
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'opacity-0' : ''
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-8 rounded-md bg-white transition-all duration-300 ease-in-out',
|
||||
isOpen ? '-translate-y-[11px] -rotate-45' : ''
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
type MobileNavigationProps = {
|
||||
navItems: readonly {
|
||||
key: string
|
||||
href: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const MobileNavigation: React.FC<MobileNavigationProps> = ({ navItems }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<AnimatedBurgerButton isOpen={isOpen} onClick={toggleMenu} />
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-50 w-full transform bg-bg-main p-6 pt-16 transition-transform duration-500 ease-in-out',
|
||||
isOpen ? 'translate-y-0' : '-translate-y-full'
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-col items-center space-y-8">
|
||||
{navItems.map((item) => (
|
||||
<a key={item.key} href={item.href} className="text-3xl" onClick={toggleMenu}>
|
||||
{t(item.key as any)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNavigation
|
||||
90
src/components/Reviews.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { reviewsSchema } from '@/content.config'
|
||||
import type { z } from 'astro:content'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/scrollbar'
|
||||
import { Mousewheel, Scrollbar } from 'swiper/modules'
|
||||
import StarIconSrc from '@/assets/icons/StarIcon.svg'
|
||||
import { useTranslations } from '@/i18n/utils'
|
||||
|
||||
type Review = z.infer<ReturnType<typeof reviewsSchema>>
|
||||
type ReviewsProps = {
|
||||
reviews: Review[]
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const REVIEWS_LINK =
|
||||
'https://yandex.ru/maps/213/moscow/?ll=37.469982%2C55.781185&mode=poi&poi%5Bpoint%5D=37.469074%2C55.781319&poi%5Buri%5D=ymapsbm1%3A%2F%2Forg%3Foid%3D19715751392%26yclid%3D10576664134676381695%26banner_id%3D1873406736351323707&tab=reviews&z=17.75'
|
||||
const swiperSlideClassName =
|
||||
'!h-auto first:pl-0 last:pr-0 md:max-w-[600px] md:first:pl-24 md:last:pr-24'
|
||||
|
||||
export const Reviews: React.FC<ReviewsProps> = ({ reviews, lang }) => {
|
||||
const t = useTranslations(lang)
|
||||
|
||||
return (
|
||||
<Swiper
|
||||
modules={[Scrollbar, Mousewheel]}
|
||||
slidesPerView={1}
|
||||
spaceBetween={24}
|
||||
scrollbar={{
|
||||
hide: true,
|
||||
draggable: true,
|
||||
}}
|
||||
breakpoints={{
|
||||
768: {
|
||||
slidesPerView: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{reviews.map((review, index) => (
|
||||
<SwiperSlide key={index} className={swiperSlideClassName}>
|
||||
<div className="flex h-full flex-col gap-6 bg-bg-light p-6 md:px-14 md:py-16">
|
||||
<div className="flex gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<img key={i} {...StarIconSrc} alt="star" className="h-5 w-5 text-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="break-words text-lg font-semibold text-text-light">
|
||||
{review.review}
|
||||
</blockquote>
|
||||
<div className="mt-auto flex items-center gap-4">
|
||||
<img
|
||||
{...review.author.image}
|
||||
alt={review.author.imageAlt}
|
||||
loading="lazy"
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-text-light">{review.author.name}</span>
|
||||
<span>{review.author.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
<SwiperSlide className={swiperSlideClassName}>
|
||||
<div className="h-full bg-bg-light transition-colors hover:bg-bg-light/80">
|
||||
<a
|
||||
href={REVIEWS_LINK}
|
||||
target="_blank"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center md:px-14 md:py-16"
|
||||
>
|
||||
<span className="text-xl font-semibold text-text-light">
|
||||
{t('reviews.viewMore.title')}
|
||||
</span>
|
||||
<span>{t('reviews.viewMore.description')}</span>
|
||||
<svg
|
||||
className="mt-2 h-6 w-6 text-text-light"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
)
|
||||
}
|
||||
34
src/components/Section.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import { cn } from '@/libs/cn'
|
||||
|
||||
type Props = {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { title, description, className, id } = Astro.props
|
||||
---
|
||||
|
||||
<section
|
||||
class={cn(
|
||||
'flex flex-col gap-6 py-10 px-6 md:p-24 md:gap-14',
|
||||
className
|
||||
)}
|
||||
id={id}
|
||||
>
|
||||
{
|
||||
(title || description) && (
|
||||
<div class="flex w-full max-w-[750px] flex-col gap-6 self-center text-center">
|
||||
{title && (
|
||||
<h2 class="text-4xl font-bold leading-tight md:text-5xl">
|
||||
<Fragment set:html={title} />
|
||||
</h2>
|
||||
)}
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</section>
|
||||
39
src/components/ServiceCard.astro
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import type { servicesSchema } from '@/content.config'
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
type Props = z.infer<typeof servicesSchema> & { highlighted?: boolean }
|
||||
|
||||
const { title, charachteristics, highlighted = false, link } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex h-full w-full flex-col border-2 px-7 py-8 md:max-w-[400px] md:min-h-[550px]',
|
||||
'border border-transparent bg-card',
|
||||
{ 'border-brand': highlighted },
|
||||
]}
|
||||
>
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-2xl font-bold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ul class="mb-16 flex-grow space-y-4">
|
||||
{
|
||||
charachteristics?.map((charachteristic) => (
|
||||
<li class="flex items-start space-x-2 justify-between">
|
||||
<span class="text-text-light">{charachteristic.text}</span>
|
||||
{charachteristic.price && <span class="ml-2 text-brand text-nowrap">{charachteristic.price}</span>}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
{
|
||||
link && (
|
||||
<a href={link.href} target={link.target} class="brand-btn-outline">
|
||||
{link.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
58
src/components/swiper/NavButtons.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ImageMetadata } from 'astro'
|
||||
import React, { useEffect, type ButtonHTMLAttributes } from 'react'
|
||||
import { useSwiper, type SwiperRef } from 'swiper/react'
|
||||
import ChevronLeft from '@/assets/icons/LeftArrowIcon.svg'
|
||||
import ChevronRight from '@/assets/icons/RightArrowIcon.svg'
|
||||
import { cn } from '@/libs/cn'
|
||||
import { useSlideChange } from './hooks'
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & { iconSrc: ImageMetadata }
|
||||
|
||||
export const NavButton: React.FC<Props> = ({ iconSrc, ...buttonProps }) => {
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'border-border-light hidden rounded-full border border-solid md:block',
|
||||
buttonProps.disabled && 'border-none',
|
||||
buttonProps.className
|
||||
)}
|
||||
>
|
||||
<img {...iconSrc} width={50} height={50} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const PrevButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
|
||||
const swiper = useSwiper()
|
||||
const [canSlidePrev, setCanSlidePrev] = React.useState(false)
|
||||
|
||||
useSlideChange(() => setCanSlidePrev(!swiper.isBeginning))
|
||||
|
||||
return (
|
||||
<NavButton
|
||||
{...props}
|
||||
className={cn('absolute left-0 top-1/2 z-10 -translate-y-1/2', props.className)}
|
||||
iconSrc={ChevronLeft}
|
||||
onClick={() => swiper.slidePrev()}
|
||||
disabled={!canSlidePrev}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const NextButton: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
|
||||
const swiper = useSwiper()
|
||||
const [canSlideNext, setCanSlideNext] = React.useState(true)
|
||||
|
||||
useSlideChange(() => setCanSlideNext(!swiper.isEnd))
|
||||
|
||||
return (
|
||||
<NavButton
|
||||
{...props}
|
||||
className={cn('absolute right-0 top-1/2 z-10 -translate-y-1/2', props.className)}
|
||||
iconSrc={ChevronRight}
|
||||
onClick={() => swiper.slideNext()}
|
||||
disabled={!canSlideNext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
src/components/swiper/Swiper.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { Swiper as SwiperBase, type SwiperRef, SwiperSlide } from 'swiper/react'
|
||||
import { A11y, Navigation, Pagination } from 'swiper/modules'
|
||||
import { cn } from '@/libs/cn'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import './styles.css'
|
||||
import { NextButton, PrevButton } from './NavButtons'
|
||||
|
||||
type SwiperProps = {
|
||||
images: (ImageMetadata & { alt: string })[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Swiper: React.FC<SwiperProps> = ({ images, className }) => {
|
||||
return (
|
||||
<SwiperBase
|
||||
modules={[Navigation, Pagination, A11y]}
|
||||
slidesPerView={1}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
}}
|
||||
className={cn('h-full w-full', className)}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<img
|
||||
{...image}
|
||||
loading="lazy"
|
||||
className="absolute h-full w-full object-cover md:px-[calc(50px+2rem)]"
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
<PrevButton />
|
||||
<NextButton />
|
||||
</SwiperBase>
|
||||
)
|
||||
}
|
||||
14
src/components/swiper/hooks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSwiper } from 'swiper/react'
|
||||
|
||||
export const useSlideChange = (onUpdate: VoidFunction) => {
|
||||
const swiper = useSwiper()
|
||||
|
||||
useEffect(() => {
|
||||
if (!swiper) return
|
||||
|
||||
swiper.on('slideChange', onUpdate)
|
||||
|
||||
return () => swiper.off('slideChange', onUpdate)
|
||||
}, [swiper])
|
||||
}
|
||||
1
src/components/swiper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Swiper';
|
||||
3
src/components/swiper/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.swiper-pagination-bullet-active {
|
||||
@apply bg-white;
|
||||
}
|
||||
75
src/content.config.ts
Normal 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,
|
||||
}
|
||||
BIN
src/content/pages/assets/coaches/1.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
src/content/pages/assets/coaches/2.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/content/pages/assets/coaches/3.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/content/pages/assets/hero.jpeg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src/content/pages/assets/reviews/1.png
Normal file
|
After Width: | Height: | Size: 596 KiB |
BIN
src/content/pages/assets/reviews/2.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/content/pages/assets/reviews/3.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
src/content/pages/assets/slider/1.jpeg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
src/content/pages/assets/slider/2.jpeg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
src/content/pages/assets/slider/3.jpeg
Normal file
|
After Width: | Height: | Size: 63 KiB |
97
src/content/pages/main.md
Normal 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
@@ -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
@@ -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];
|
||||
};
|
||||
}
|
||||
23
src/layouts/BaseLayout.astro
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||