Initial commit: restructure project with Docker Compose setup

This commit is contained in:
Admin
2026-05-26 20:16:01 +05:00
commit 47b15787f3
48 changed files with 6074 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
### Dependencies ###
node_modules/
### Build outputs ###
dist/
build/
### Environment files ###
.env
!.env.example
### Logs ###
logs/
*.log
npm-debug.log*
### IDE ###
.idea/
.vscode/
*.iml
### OS ###
.DS_Store
Thumbs.db
### Docker volumes (if any) ###
data/
+74
View File
@@ -0,0 +1,74 @@
# Сайт психолога Ирины
## Структура проекта
```
.
├── backend/ # NestJS API (отправка заявок на почту)
├── frontend/ # React + Vite + TailwindCSS
├── docker-compose.yml
└── .env # Общие переменные окружения
```
## Быстрый старт (Docker Compose)
1. **Заполни переменные окружения** в корневом `.env`:
```bash
cp .env.example .env
# Отредактируй .env — укажи свои данные Яндекс.Почты
```
2. **Запусти проект:**
```bash
docker compose up --build
```
- Фронтенд: http://localhost
- Бэкенд API: http://localhost:3001
## Локальная разработка (без Docker)
### Backend
```bash
cd backend
cp .env.example .env
# Отредактируй .env
npm install
npm run start:dev
```
### Frontend
```bash
cd frontend
cp .env.example .env
# Для dev-режима VITE_API_URL=http://localhost:3001
npm install
npm run dev
```
## Переменные окружения
| Переменная | Описание |
|------------|----------|
| `PORT` | Порт бэкенда (по умолчанию 3001) |
| `SMTP_HOST` | SMTP сервер (smtp.yandex.ru) |
| `SMTP_PORT` | SMTP порт (465) |
| `SMTP_SECURE` | Использовать SSL (true) |
| `SMTP_USER` | Логин почты |
| `SMTP_PASS` | Пароль приложения |
| `RECIPIENT_EMAIL` | Почта для получения заявок |
| `SITE_NAME` | Название сайта |
| `FRONTEND_URL` | Разрешённый origin для CORS |
| `VITE_API_URL` | URL API для фронтенда |
## Архитектура
- **Frontend**: React 18 + Vite + TailwindCSS + React Router + i18next
- **Backend**: NestJS + Nodemailer (SMTP Яндекса)
- **API endpoint**: `POST /api/mail/application`
- Принимает `{ name: string, phone: string, email?: string, message?: string }`
- Отправляет письмо на указанный `RECIPIENT_EMAIL`
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env
+18
View File
@@ -0,0 +1,18 @@
# Server
PORT=3001
# SMTP (Яндекс)
SMTP_HOST=smtp.yandex.ru
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=your-email@yandex.ru
SMTP_PASS=your-app-password
# Email recipient
RECIPIENT_EMAIL=your-email@yandex.ru
# Site info
SITE_NAME=Ирина — Практикующий психолог
# CORS allowed origin (для Docker: http://localhost, для dev: http://localhost:5173)
FRONTEND_URL=http://localhost
+17
View File
@@ -0,0 +1,17 @@
# Этап сборки
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Продакшен этап
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/main.js"]
+1853
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "psychologist-backend",
"version": "1.0.0",
"description": "Backend for psychologist website - email applications",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
"start:debug": "ts-node --inspect src/main.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/config": "^3.1.1",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"nodemailer": "^6.9.8",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"@types/nodemailer": "^6.4.14",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MailModule } from './mail/mail.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MailModule,
],
})
export class AppModule {}
@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsEmail, MinLength } from 'class-validator';
export class SendApplicationDto {
@IsString()
@MinLength(1)
name: string;
@IsString()
@MinLength(1)
phone: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
message?: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { Controller, Post, Body } from '@nestjs/common';
import { MailService } from './mail.service';
import { SendApplicationDto } from './dto/send-application.dto';
@Controller('api/mail')
export class MailController {
constructor(private readonly mailService: MailService) {}
@Post('application')
async sendApplication(@Body() dto: SendApplicationDto) {
await this.mailService.sendApplication(dto);
return { success: true, message: 'Application sent successfully' };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { MailController } from './mail.controller';
@Module({
providers: [MailService],
controllers: [MailController],
})
export class MailModule {}
+84
View File
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { SendApplicationDto } from './dto/send-application.dto';
@Injectable()
export class MailService {
private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get('SMTP_HOST', 'smtp.yandex.ru'),
port: parseInt(this.configService.get('SMTP_PORT', '465'), 10),
secure: this.configService.get('SMTP_SECURE', 'true') === 'true',
auth: {
user: this.configService.getOrThrow('SMTP_USER'),
pass: this.configService.getOrThrow('SMTP_PASS'),
},
});
}
async sendApplication(dto: SendApplicationDto) {
const recipientEmail = this.configService.get('RECIPIENT_EMAIL') || this.configService.get('SMTP_USER');
const siteName = this.configService.get('SITE_NAME', 'Сайт психолога');
const html = `
<h2>Новая заявка с сайта «${siteName}»</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px;font-family:sans-serif">
<tr style="border-bottom:1px solid #eee">
<td style="padding:10px 0;font-weight:bold;width:140px">Имя:</td>
<td style="padding:10px 0">${this.escapeHtml(dto.name)}</td>
</tr>
<tr style="border-bottom:1px solid #eee">
<td style="padding:10px 0;font-weight:bold">Телефон:</td>
<td style="padding:10px 0">${this.escapeHtml(dto.phone)}</td>
</tr>
${dto.email ? `
<tr style="border-bottom:1px solid #eee">
<td style="padding:10px 0;font-weight:bold">Email:</td>
<td style="padding:10px 0">${this.escapeHtml(dto.email)}</td>
</tr>
` : ''}
${dto.message ? `
<tr style="border-bottom:1px solid #eee">
<td style="padding:10px 0;font-weight:bold">Сообщение:</td>
<td style="padding:10px 0">${this.escapeHtml(dto.message).replace(/\n/g, '<br>')}</td>
</tr>
` : ''}
<tr>
<td style="padding:10px 0;font-weight:bold">Дата:</td>
<td style="padding:10px 0">${new Date().toLocaleString('ru-RU')}</td>
</tr>
</table>
`;
const text = `
Новая заявка с сайта «${siteName}»
Имя: ${dto.name}
Телефон: ${dto.phone}
${dto.email ? `Email: ${dto.email}\n` : ''}${dto.message ? `Сообщение: ${dto.message}\n` : ''}
Дата: ${new Date().toLocaleString('ru-RU')}
`.trim();
await this.transporter.sendMail({
from: `"${siteName}" <${this.configService.get('SMTP_USER')}>`,
to: recipientEmail,
subject: `Новая заявка от ${dto.name}`,
text,
html,
});
return { success: true };
}
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.FRONTEND_URL || true,
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
}
bootstrap();
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+31
View File
@@ -0,0 +1,31 @@
version: '3.8'
services:
backend:
build: ./backend
container_name: psychologist-backend
restart: unless-stopped
ports:
- "3001:3001"
env_file:
- .env
networks:
- app-network
frontend:
build:
context: ./frontend
args:
- VITE_API_URL=/api
container_name: psychologist-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
networks:
- app-network
networks:
app-network:
driver: bridge
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env
+4
View File
@@ -0,0 +1,4 @@
# URL backend API (для локальной разработки без Docker)
VITE_API_URL=http://localhost:3001
# Для production сборки в Docker используется /api (через nginx proxy)
+16
View File
@@ -0,0 +1,16 @@
# Этап сборки
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
# Продакшен этап
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ирина — психолог. Индивидуальная терапия</title>
<meta name="description" content="Дипломированный практикующий психолог Ирина. Индивидуальные консультации, интегративный подход. Запишитесь на бесплатное знакомство.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Manrope:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.5.0/remixicon.min.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
+2616
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "psychologist",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"i18next": "^23.8.0",
"react-i18next": "^14.0.0",
"i18next-browser-languagedetector": "^7.2.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+8
View File
@@ -0,0 +1,8 @@
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
function App() {
return <RouterProvider router={router} />;
}
export default App;
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
delay?: number;
}
export default function ScrollReveal({ children, className = "", delay = 0 }: ScrollRevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${className} ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { ru } from './local';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ru: { translation: ru },
},
fallbackLng: 'ru',
interpolation: {
escapeValue: false,
},
});
export default i18n;
+20
View File
@@ -0,0 +1,20 @@
export const ru = {
nav: {
about: 'Обо мне',
approach: 'Подход',
process: 'Как работаем',
issues: 'Запросы',
pricing: 'Стоимость',
contact: 'Записаться',
},
hero: {
quote: 'Терапия — не про поиск «лучшей версии себя». Это про смелость наконец-то увидеть и стать собой.',
subtitle: 'Здесь безопасное пространство для вашей внутренней работы.',
cta: 'Записаться на бесплатное знакомство',
},
form: {
namePlaceholder: 'Ваше имя',
phonePlaceholder: 'Номер телефона',
submit: 'Записаться',
},
};
+41
View File
@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-cream text-warmBrown antialiased;
font-family: 'Manrope', sans-serif;
}
}
@layer utilities {
.text-softBrown {
color: #6B6560;
}
}
@keyframes scaleIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes fadeInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+18
View File
@@ -0,0 +1,18 @@
import { useLocation } from "react-router-dom";
export default function NotFound() {
const location = useLocation();
return (
<div className="relative flex flex-col items-center justify-center h-screen text-center px-4">
<h1 className="absolute bottom-0 text-9xl md:text-[12rem] font-black text-gray-50 select-none pointer-events-none z-0">
404
</h1>
<div className="relative z-10">
<h1 className="text-xl md:text-2xl font-semibold mt-6">This page has not been generated</h1>
<p className="mt-2 text-base text-gray-400 font-mono">{location.pathname}</p>
<p className="mt-4 text-lg md:text-xl text-gray-500">Tell me more about this page, so I can generate it</p>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { useState } from "react";
import ScrollReveal from "@/components/feature/ScrollReveal";
const diplomas = [
{ label: "Диплом о высшем образовании" },
{ label: "Сертификат повышения квалификации" },
];
export default function AboutSection() {
const [previewIdx, setPreviewIdx] = useState<number | null>(null);
return (
<section id="about" className="bg-white py-20 md:py-28 lg:py-36">
<div className="w-full px-6 md:px-10 lg:px-16">
<div className="flex flex-col lg:flex-row gap-12 lg:gap-20">
{/* Left column — text */}
<div className="w-full lg:w-[45%] flex flex-col">
<ScrollReveal>
<span className="inline-block self-start px-3 py-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.2em] text-mutedBrown/60 border border-greigeDark rounded-full mb-8 md:mb-12">
Обо мне
</span>
</ScrollReveal>
<ScrollReveal delay={100}>
<h2 className="font-serif text-3xl md:text-4xl lg:text-[2.5rem] font-semibold text-warmBrown leading-tight">
<span className="block">Меня зовут</span>
<span className="block font-light text-mutedBrown/70 mt-1">Ирина</span>
</h2>
</ScrollReveal>
<ScrollReveal delay={200}>
<div className="mt-8 md:mt-12 space-y-5 text-[15px] md:text-base font-sans font-normal text-softBrown leading-[1.75]">
<p>
Я дипломированный практикующий психолог, веду индивидуальные консультации более 3 лет.
</p>
<p>
Регулярно прохожу личную терапию, супервизию, интервизию. Работаю только со взрослыми от 18 лет.
</p>
<p>
Со мной мои клиенты избавляются от тревожности, приходят к стабильной самооценке, налаживают отношения, проявляются легко и свободно.
</p>
</div>
</ScrollReveal>
<ScrollReveal delay={300}>
<div className="mt-8 md:mt-12 pt-6 border-t border-greigeDark/40 flex flex-col sm:flex-row gap-6 sm:gap-10">
<div>
<p className="font-serif text-3xl md:text-4xl font-bold text-warmBrown">3+</p>
<p className="mt-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.15em] text-mutedBrown/60">Лет практики</p>
</div>
<div>
<p className="font-serif text-3xl md:text-4xl font-bold text-warmBrown">100+</p>
<p className="mt-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.15em] text-mutedBrown/60">Клиентов</p>
</div>
</div>
</ScrollReveal>
</div>
{/* Right column — photo + diplomas */}
<div className="w-full lg:w-[55%] flex flex-col gap-8 md:gap-10">
{/* Large photo placeholder */}
<ScrollReveal delay={200}>
<div className="w-full aspect-[3/4] max-h-[520px] bg-greige rounded-2xl flex items-center justify-center overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-b from-white/10 to-greigeDark/20"></div>
<span className="relative z-10 font-serif text-lg md:text-xl text-mutedBrown/50 italic">
Фото психолога
</span>
</div>
</ScrollReveal>
{/* Two diplomas — clickable previews */}
<div className="flex flex-col sm:flex-row gap-4 md:gap-6">
{diplomas.map((d, i) => (
<ScrollReveal key={i} delay={300 + i * 120} className="flex-1">
<button
onClick={() => setPreviewIdx(i)}
className="group relative w-full aspect-[4/3] bg-greige rounded-xl flex items-center justify-center overflow-hidden cursor-pointer text-left"
>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span className="flex items-center gap-2 px-4 py-2 bg-white/90 rounded-full text-xs font-sans font-medium text-warmBrown">
<i className="ri-eye-line"></i>
Просмотреть
</span>
</div>
<span className="relative z-10 font-serif text-sm md:text-base text-mutedBrown/50 italic">
{d.label}
</span>
</button>
</ScrollReveal>
))}
</div>
</div>
</div>
</div>
{/* Diploma preview modal */}
{previewIdx !== null && (
<div
className="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm flex items-center justify-center p-6"
onClick={() => setPreviewIdx(null)}
>
<div
className="bg-white rounded-2xl p-8 md:p-12 max-w-lg w-full text-center shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full aspect-[3/4] bg-greige rounded-xl flex items-center justify-center mb-6">
<span className="font-serif text-lg text-mutedBrown/50 italic">
{diplomas[previewIdx].label}
</span>
</div>
<p className="text-sm font-sans font-light text-mutedBrown mb-6">
PDF будет доступен для просмотра в ближайшее время.
</p>
<button
onClick={() => setPreviewIdx(null)}
className="inline-flex items-center gap-2 px-6 py-2.5 bg-taupe hover:bg-taupeDark text-white font-sans text-xs font-medium uppercase tracking-[0.1em] rounded-md transition-colors duration-300"
>
Закрыть
</button>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,70 @@
import ScrollReveal from "@/components/feature/ScrollReveal";
export default function ApproachSection() {
return (
<section id="approach" className="bg-cream py-20 md:py-28 lg:py-36">
<div className="w-full px-6 md:px-10 lg:px-16">
<div className="max-w-4xl mx-auto text-center">
<ScrollReveal>
<span className="inline-block px-3 py-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.2em] text-mutedBrown/60 border border-greigeDark rounded-full mb-8 md:mb-10">
Мой подход
</span>
</ScrollReveal>
<ScrollReveal delay={100}>
<h2 className="font-serif text-3xl md:text-4xl lg:text-[2.5rem] font-semibold text-warmBrown leading-tight">
Я работаю в интегративном подходе.
<br />
<span className="font-light italic text-mutedBrown/70">Что это значит для вас?</span>
</h2>
</ScrollReveal>
<ScrollReveal delay={250}>
<p className="mt-8 md:mt-12 text-sm md:text-[15px] font-sans font-normal text-softBrown leading-[1.85] max-w-2xl mx-auto">
Я не загоняю в рамки одного метода, а подбираю то, что подойдёт именно вам. Это объединение лучшего из разных направлений. Такой подход помогает разобраться в вашей ситуации и максимально быстро прийти к желаемому результату в вашем темпе как через разум, так и через чувства.
</p>
</ScrollReveal>
<ScrollReveal delay={400}>
<div className="mt-12 md:mt-16 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{[
{
title: "ЭОТ",
desc: "Эмоционально-образная терапия — работа с глубокими эмоциями и состояниями",
},
{
title: "Транзактный анализ",
desc: "Чтобы понять сценарии поведения",
},
{
title: "Гештальт-терапия",
desc: "Возвращение контакта с собой и своими желаниями",
},
{
title: "МАК и Арт-терапия",
desc: "Когда сложно подобрать слова, а чувства переполняют",
},
{
title: "КПТ",
desc: "Пересмотр мышления, убеждений и поведенческих паттернов",
},
].map((item, i) => (
<div
key={i}
className="bg-white/60 backdrop-blur-sm rounded-xl p-6 md:p-8 border border-greigeDark/30 text-left hover:bg-white/80 hover:-translate-y-1 hover:shadow-md transition-all duration-300"
>
<h3 className="font-serif text-lg md:text-xl font-semibold text-warmBrown">
{item.title}
</h3>
<p className="mt-3 text-sm md:text-[15px] font-sans font-normal text-mutedBrown leading-relaxed">
{item.desc}
</p>
</div>
))}
</div>
</ScrollReveal>
</div>
</div>
</section>
);
}
@@ -0,0 +1,61 @@
import ScrollReveal from "@/components/feature/ScrollReveal";
const socials = [
{ icon: "ri-telegram-line", label: "Telegram" },
{ icon: "ri-vk-line", label: "ВКонтакте" },
{ icon: "ri-instagram-line", label: "Instagram" },
{ icon: "ri-message-3-line", label: "Макси" },
{ icon: "ri-article-line", label: "b17.ru" },
];
export default function FooterSection() {
return (
<footer className="bg-greige/50 py-16 md:py-24">
<div className="w-full px-6 md:px-10 lg:px-16">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-10 md:gap-16">
<div>
<ScrollReveal>
<p className="text-sm md:text-base font-sans font-light text-mutedBrown/70 leading-relaxed max-w-xs">
Если остались вопросы всегда рада помочь. Пишите или звоните.
</p>
<a
href="tel:+79999999999"
className="inline-block mt-3 font-serif text-xl md:text-2xl font-medium text-warmBrown hover:text-taupe transition-colors duration-300"
>
+7 999 999-99-99
</a>
</ScrollReveal>
</div>
<ScrollReveal delay={150}>
<div className="flex flex-col items-end gap-4">
<p className="text-xs md:text-sm font-sans font-light text-mutedBrown/60 italic">
Больше обо мне в социальных сетях
</p>
<div className="flex items-center gap-3 md:gap-4">
{socials.map((s, i) => (
<span
key={i}
className="w-10 h-10 md:w-11 md:h-11 flex items-center justify-center rounded-full bg-white/70 text-warmBrown/70 hover:text-warmBrown hover:bg-white transition-all duration-300 cursor-default"
title={s.label}
>
<i className={`${s.icon} text-lg`}></i>
</span>
))}
</div>
</div>
</ScrollReveal>
</div>
<div className="mt-12 md:mt-16 pt-6 border-t border-greigeDark/40 flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-xs font-sans font-light text-mutedBrown/60">
© 2026 Ирина. Все права защищены.
</p>
<p className="text-xs font-sans font-light text-mutedBrown/60">
Практикующий психолог
</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,37 @@
import ScrollReveal from "@/components/feature/ScrollReveal";
export default function HeroSection() {
return (
<section className="relative min-h-screen flex items-center bg-cream overflow-hidden">
<div className="w-full px-6 md:px-10 lg:px-16 py-20 md:py-0">
<div className="flex flex-col items-center justify-center min-h-screen max-w-4xl mx-auto text-center pt-20 lg:pt-0">
<ScrollReveal>
<blockquote className="font-serif text-3xl md:text-4xl lg:text-[2.75rem] xl:text-5xl font-medium text-warmBrown leading-[1.25] tracking-tight">
«Терапия не про поиск «лучшей версии себя». Это про смелость наконец-то увидеть и&nbsp;стать&nbsp;собой.»
</blockquote>
</ScrollReveal>
<ScrollReveal delay={200}>
<p className="mt-6 md:mt-8 text-base md:text-lg font-sans font-normal text-mutedBrown leading-relaxed max-w-lg">
Здесь безопасное пространство для вашей внутренней работы.
</p>
</ScrollReveal>
<ScrollReveal delay={400}>
<div className="mt-8 md:mt-10 flex items-center justify-center gap-3">
<a
href="#contact"
className="inline-flex items-center gap-2 px-7 py-3.5 bg-taupe hover:bg-taupeDark text-white font-sans text-xs md:text-sm font-medium uppercase tracking-[0.1em] rounded-md transition-colors duration-300"
>
Записаться на бесплатное знакомство
</a>
<span className="w-10 h-10 md:w-11 md:h-11 flex items-center justify-center border border-taupe/40 rounded-md text-taupe hover:border-taupe hover:bg-taupe/5 transition-all duration-300 cursor-pointer">
<i className="ri-arrow-right-line text-lg"></i>
</span>
</div>
</ScrollReveal>
</div>
</div>
</section>
);
}
@@ -0,0 +1,54 @@
import ScrollReveal from "@/components/feature/ScrollReveal";
const issues = [
{ title: "Сложности в отношениях и общении", bg: "bg-[#F0E6E0]", icon: "ri-heart-3-line" },
{ title: "Одиночество", bg: "bg-[#E8E0EC]", icon: "ri-user-unfollow-line" },
{ title: "Потеря контакта с собой", bg: "bg-[#EDE8E0]", icon: "ri-user-search-line" },
{ title: "Неудовлетворённость жизнью", bg: "bg-[#E8E8E0]", icon: "ri-emotion-unhappy-line" },
{ title: "Неспособность справляться со своими чувствами", bg: "bg-[#F0E6E0]", icon: "ri-mental-health-line" },
{ title: "Стыд и чувство вины", bg: "bg-[#E8E0EC]", icon: "ri-shield-user-line" },
{ title: "Тревога и панические атаки", bg: "bg-[#EDE8E0]", icon: "ri-heart-pulse-line" },
{ title: "Апатия и потеря сил", bg: "bg-[#F5EDE5]", icon: "ri-battery-low-line" },
{ title: "Низкая самооценка", bg: "bg-[#E8E8E0]", icon: "ri-bar-chart-grouped-line" },
{ title: "Эмоциональная зависимость", bg: "bg-[#F0E6E0]", icon: "ri-links-line" },
{ title: "Страх", bg: "bg-[#E8E0EC]", icon: "ri-alarm-warning-line" },
{ title: "Навязчивые мысли, ком в горле, тяжесть в груди", bg: "bg-[#EDE8E0]", icon: "ri-brain-line" },
];
export default function IssuesSection() {
return (
<section id="issues" className="bg-cream py-20 md:py-28 lg:py-36">
<div className="w-full px-6 md:px-10 lg:px-16">
<ScrollReveal>
<div className="mb-12 md:mb-20">
<span className="inline-block px-3 py-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.2em] text-mutedBrown/60 border border-greigeDark rounded-full mb-6 md:mb-8">
Запросы
</span>
<h2 className="font-serif text-3xl md:text-4xl lg:text-[2.5rem] font-semibold text-warmBrown leading-tight max-w-2xl">
С какими запросами
<br />
<span className="font-light italic text-mutedBrown/70">я работаю?</span>
</h2>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4">
{issues.map((issue, i) => (
<ScrollReveal key={i} delay={i * 40}>
<div
className={`${issue.bg} rounded-xl p-4 md:p-5 h-full flex items-center gap-3 md:gap-4 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-sm transition-all duration-300 cursor-default`}
>
<div className="w-10 h-10 md:w-11 md:h-11 rounded-lg bg-white/60 flex items-center justify-center flex-shrink-0">
<i className={`${issue.icon} text-warmBrown/50 text-lg`}></i>
</div>
<h3 className="text-sm md:text-[15px] font-sans font-medium text-warmBrown leading-snug">
{issue.title}
</h3>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,144 @@
import { useState, useEffect } from "react";
const navLinks = [
{ label: "Обо мне", href: "#about" },
{ label: "Подход", href: "#approach" },
{ label: "Как работаем", href: "#process" },
{ label: "Запросы", href: "#issues" },
{ label: "Стоимость", href: "#pricing" },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 60);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
e.preventDefault();
const el = document.querySelector(href);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
setMobileOpen(false);
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${
scrolled
? "bg-white/90 backdrop-blur-md shadow-sm"
: "bg-transparent"
}`}
>
<div className="w-full px-6 md:px-10 lg:px-16">
<div className="flex items-center justify-between h-16 md:h-20">
{/* Left: empty spacer for balance */}
<div className="hidden md:block w-40" />
{/* Center: nav links */}
<div className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
onClick={(e) => handleNavClick(e, link.href)}
className="text-xs font-sans font-medium uppercase tracking-[0.15em] text-warmBrown/80 hover:text-warmBrown transition-colors duration-300"
>
{link.label}
</a>
))}
</div>
{/* Right: messengers + CTA */}
<div className="hidden md:flex items-center gap-4 w-40 justify-end">
<div className="flex items-center gap-2">
<a
href="https://t.me/"
target="_blank"
rel="noopener noreferrer"
aria-label="Telegram"
className="w-9 h-9 rounded-full bg-greige flex items-center justify-center text-warmBrown/70 hover:bg-greigeDark hover:text-warmBrown transition-all duration-300"
>
<i className="ri-telegram-line text-base"></i>
</a>
<a
href="https://max.ru/"
target="_blank"
rel="noopener noreferrer"
aria-label="МАКС"
className="w-9 h-9 rounded-full bg-greige flex items-center justify-center text-warmBrown/70 hover:bg-greigeDark hover:text-warmBrown transition-all duration-300"
>
<i className="ri-message-3-line text-base"></i>
</a>
</div>
<a
href="#contact"
onClick={(e) => handleNavClick(e, "#contact")}
className="px-5 py-2 bg-taupe hover:bg-taupeDark text-white font-sans text-xs font-medium uppercase tracking-[0.1em] rounded-full transition-colors duration-300 whitespace-nowrap"
>
Записаться
</a>
</div>
{/* Mobile burger */}
<button
className="md:hidden w-10 h-10 flex items-center justify-center text-warmBrown ml-auto"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Menu"
>
<i className={`ri-${mobileOpen ? "close" : "menu"}-line text-xl`}></i>
</button>
</div>
</div>
{mobileOpen && (
<div className="md:hidden bg-white/95 backdrop-blur-md border-t border-greigeDark/30 px-6 py-6 flex flex-col gap-4">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
onClick={(e) => handleNavClick(e, link.href)}
className="text-sm font-sans font-medium uppercase tracking-[0.1em] text-warmBrown/80 hover:text-warmBrown transition-colors"
>
{link.label}
</a>
))}
<div className="flex items-center gap-3 mt-2 pt-4 border-t border-greigeDark/20">
<a
href="https://t.me/"
target="_blank"
rel="noopener noreferrer"
aria-label="Telegram"
className="w-10 h-10 rounded-full bg-greige flex items-center justify-center text-warmBrown/70"
>
<i className="ri-telegram-line text-lg"></i>
</a>
<a
href="https://max.ru/"
target="_blank"
rel="noopener noreferrer"
aria-label="МАКС"
className="w-10 h-10 rounded-full bg-greige flex items-center justify-center text-warmBrown/70"
>
<i className="ri-message-3-line text-lg"></i>
</a>
<a
href="#contact"
onClick={(e) => handleNavClick(e, "#contact")}
className="flex-1 text-center px-5 py-2.5 bg-taupe hover:bg-taupeDark text-white font-sans text-xs font-medium uppercase tracking-[0.1em] rounded-full transition-colors duration-300"
>
Записаться
</a>
</div>
</div>
)}
</nav>
);
}
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import ScrollReveal from "@/components/feature/ScrollReveal";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
export default function PricingSection() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const data = {
name: String(formData.get("name") || "").trim(),
phone: String(formData.get("phone") || "").trim(),
};
try {
const res = await fetch(`${API_URL}/api/mail/application`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "Ошибка отправки заявки");
}
navigate("/thanks");
} catch (err: any) {
setError(err.message || "Не удалось отправить заявку. Попробуйте позже.");
} finally {
setLoading(false);
}
};
return (
<section id="pricing" className="bg-white py-20 md:py-28 lg:py-36">
<div className="w-full px-6 md:px-10 lg:px-16">
<div className="max-w-3xl mx-auto text-center">
<ScrollReveal>
<span className="inline-block px-3 py-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.2em] text-mutedBrown/60 border border-greigeDark rounded-full mb-6 md:mb-8">
Стоимость
</span>
</ScrollReveal>
<ScrollReveal delay={100}>
<h2 className="font-serif text-3xl md:text-4xl lg:text-[2.5rem] font-semibold text-warmBrown leading-tight">
Стоимость и формат
</h2>
</ScrollReveal>
<ScrollReveal delay={200}>
<div className="mt-8 md:mt-12 bg-cream rounded-2xl p-8 md:p-12 border border-greigeDark/30">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 md:gap-8">
<div className="text-center">
<span className="block font-serif text-2xl md:text-3xl font-semibold text-warmBrown">6080 мин</span>
<span className="block mt-2 text-xs md:text-sm font-sans font-normal text-mutedBrown uppercase tracking-wider">Длительность сессии</span>
</div>
<div className="text-center sm:border-x sm:border-greigeDark/30">
<span className="block font-serif text-2xl md:text-3xl font-semibold text-warmBrown">2 500 </span>
<span className="block mt-2 text-xs md:text-sm font-sans font-normal text-mutedBrown uppercase tracking-wider">Стоимость</span>
</div>
<div className="text-center">
<span className="block font-serif text-2xl md:text-3xl font-semibold text-taupe">Бесплатно</span>
<span className="block mt-2 text-xs md:text-sm font-sans font-normal text-mutedBrown uppercase tracking-wider">Первая встреча</span>
</div>
</div>
<div className="mt-8 md:mt-10 pt-6 md:pt-8 border-t border-greigeDark/30">
<p className="text-sm md:text-[15px] font-sans font-normal text-softBrown">
Более 100 клиентов решили свои запросы
</p>
</div>
</div>
</ScrollReveal>
<ScrollReveal delay={250}>
<div className="mt-6 md:mt-8 bg-cream/60 rounded-xl p-6 md:p-8 border border-greigeDark/20">
<h3 className="font-serif text-lg md:text-xl font-semibold text-warmBrown mb-3">
Условия отмены и переноса
</h3>
<p className="text-sm md:text-[15px] font-sans font-normal text-softBrown leading-relaxed">
Отмена или перенос не позднее чем за 24 часа до сессии. При отмене с моей стороны следующая сессия проводится бесплатно.
</p>
</div>
</ScrollReveal>
<ScrollReveal delay={300}>
<div className="mt-8 md:mt-10" id="contact">
<form
onSubmit={handleSubmit}
className="inline-flex flex-col sm:flex-row items-center gap-3"
>
<input
type="text"
name="name"
placeholder="Ваше имя"
required
disabled={loading}
className="px-5 py-3.5 bg-cream border border-greigeDark/50 rounded-full text-sm font-sans text-warmBrown placeholder:text-mutedBrown/50 focus:outline-none focus:border-taupe transition-colors w-64 disabled:opacity-60"
/>
<input
type="tel"
name="phone"
placeholder="Номер телефона"
required
disabled={loading}
className="px-5 py-3.5 bg-cream border border-greigeDark/50 rounded-full text-sm font-sans text-warmBrown placeholder:text-mutedBrown/50 focus:outline-none focus:border-taupe transition-colors w-64 disabled:opacity-60"
/>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-2 px-7 py-3.5 bg-taupe hover:bg-taupeDark text-white font-sans text-xs md:text-sm font-medium uppercase tracking-[0.1em] rounded-full transition-colors duration-300 whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? "Отправка..." : "Записаться"}
<span className="w-5 h-5 flex items-center justify-center">
<i className="ri-arrow-right-line text-sm"></i>
</span>
</button>
</form>
{error && (
<p className="mt-3 text-sm text-red-500 font-sans">{error}</p>
)}
</div>
</ScrollReveal>
</div>
</div>
</section>
);
}
@@ -0,0 +1,73 @@
import ScrollReveal from "@/components/feature/ScrollReveal";
const steps = [
{
num: "01",
title: "Знакомство и маршрут",
desc: "На первой встрече мы проводим ревизию того, что происходит в вашей жизни сейчас. 20–30 минут — достаточно, чтобы я услышала вашу ситуацию, а вы поняли, подхожу ли я вам. Вместе переведём проблему в понятную цель, подберём формат и график встреч.",
highlight: "Это время я провожу безоплатно.",
},
{
num: "02",
title: "Психологическая диагностика",
desc: "«Чек-ап» нервной системы: 7 тестов для оценки уровня тревоги, депрессии, агрессии, невроза, перфекционизма, алекситимии и определение психотипа. Уходим от догадок к фактам и отслеживаем реальную динамику изменений.",
highlight: "Прохождение обязательно.",
},
{
num: "03",
title: "Трансформация и автономия",
desc: "Работаем над запросом и внедряем изменения в реальную жизнь — не только на сессиях, но и между ними. Цель — устойчивый результат и инструменты, которые позволят вам справляться самостоятельно.",
highlight: "",
},
];
export default function ProcessSection() {
return (
<section id="process" className="bg-white py-20 md:py-28 lg:py-36">
<div className="w-full px-6 md:px-10 lg:px-16">
<ScrollReveal>
<div className="text-center mb-12 md:mb-20">
<span className="inline-block px-3 py-1 text-[10px] md:text-xs font-sans font-medium uppercase tracking-[0.2em] text-mutedBrown/60 border border-greigeDark rounded-full mb-6 md:mb-8">
Процесс
</span>
<h2 className="font-serif text-3xl md:text-4xl lg:text-[2.5rem] font-semibold text-warmBrown leading-tight">
Как строится наша работа?
</h2>
</div>
</ScrollReveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{steps.map((step, i) => (
<ScrollReveal key={i} delay={i * 150}>
<div className="group relative h-full bg-greige/40 rounded-2xl overflow-hidden hover:bg-greige/60 hover:-translate-y-1 hover:shadow-md transition-all duration-500">
<div className="absolute top-0 left-0 w-full h-full">
<div className="w-full h-48 md:h-56 bg-gradient-to-b from-powder/40 via-lavender/20 to-greige/60"></div>
</div>
<div className="relative z-10 p-6 md:p-8 flex flex-col h-full">
<span className="font-serif text-5xl md:text-6xl font-bold text-warmBrown/10 group-hover:text-warmBrown/15 transition-colors duration-500">
{step.num}
</span>
<h3 className="mt-4 md:mt-6 font-serif text-xl md:text-2xl font-semibold text-warmBrown">
{step.title}
</h3>
<p className="mt-3 md:mt-4 text-sm md:text-[15px] font-sans font-normal text-softBrown leading-[1.75] flex-grow">
{step.desc}
</p>
{step.highlight && (
<p className="mt-4 text-sm md:text-[15px] font-sans font-medium text-taupe italic">
{step.highlight}
</p>
)}
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,8 @@
export { default as Navbar } from './Navbar';
export { default as HeroSection } from './HeroSection';
export { default as AboutSection } from './AboutSection';
export { default as ApproachSection } from './ApproachSection';
export { default as ProcessSection } from './ProcessSection';
export { default as IssuesSection } from './IssuesSection';
export { default as PricingSection } from './PricingSection';
export { default as FooterSection } from './FooterSection';
+23
View File
@@ -0,0 +1,23 @@
import Navbar from "./components/Navbar";
import HeroSection from "./components/HeroSection";
import AboutSection from "./components/AboutSection";
import ApproachSection from "./components/ApproachSection";
import ProcessSection from "./components/ProcessSection";
import IssuesSection from "./components/IssuesSection";
import PricingSection from "./components/PricingSection";
import FooterSection from "./components/FooterSection";
export default function HomePage() {
return (
<div className="min-h-screen bg-cream font-sans text-warmBrown antialiased">
<Navbar />
<HeroSection />
<AboutSection />
<ApproachSection />
<ProcessSection />
<IssuesSection />
<PricingSection />
<FooterSection />
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
const socials = [
{ icon: "ri-telegram-line", label: "Telegram", href: "https://t.me/" },
{ icon: "ri-message-3-line", label: "МАКС", href: "https://max.ru/" },
{ icon: "ri-vk-line", label: "ВКонтакте", href: "https://vk.com/" },
{ icon: "ri-instagram-line", label: "Instagram", href: "https://instagram.com/" },
{ icon: "ri-article-line", label: "b17.ru", href: "https://b17.ru/" },
];
const steps = [
"Я свяжусь с вами в течение 24 часов",
"Мы выберем удобное время для знакомства",
"Первая встреча — бесплатно, без обязательств",
];
export default function ThanksPage() {
const navigate = useNavigate();
useEffect(() => {
document.body.style.opacity = "0";
requestAnimationFrame(() => {
document.body.style.transition = "opacity 0.7s ease";
document.body.style.opacity = "1";
});
return () => {
document.body.style.opacity = "";
document.body.style.transition = "";
};
}, []);
return (
<div className="min-h-screen bg-cream flex flex-col antialiased">
{/* Header */}
<header className="w-full px-6 md:px-10 lg:px-16 py-6 md:py-8">
<div className="flex items-center justify-between">
<span className="font-serif text-xl md:text-2xl font-medium text-warmBrown">
Ирина
</span>
<button
onClick={() => navigate("/")}
className="text-xs md:text-sm font-sans font-medium uppercase tracking-[0.1em] text-warmBrown/60 hover:text-warmBrown transition-colors duration-300"
>
На главную
</button>
</div>
</header>
{/* Main */}
<main className="flex-1 flex flex-col items-center justify-center px-6 md:px-10 lg:px-16 py-12 md:py-20 lg:py-28">
<div className="max-w-xl w-full text-center">
{/* Check icon */}
<div className="w-16 h-16 md:w-20 md:h-20 mx-auto mb-8 rounded-full bg-greige/60 flex items-center justify-center animate-[scaleIn_0.5s_ease-out]">
<i className="ri-check-line text-3xl md:text-4xl text-taupe"></i>
</div>
<h1 className="font-serif text-3xl md:text-4xl lg:text-5xl font-semibold text-warmBrown leading-tight mb-6 animate-[fadeInUp_0.6s_ease-out_0.1s_both]">
Спасибо, что написали
</h1>
<p className="text-base md:text-lg font-sans font-normal text-softBrown leading-relaxed mb-12 animate-[fadeInUp_0.6s_ease-out_0.2s_both]">
Я получила вашу заявку и напишу вам в ближайшее время. Сделайте себе чай вы уже сделали важный шаг.
</p>
{/* Steps block */}
<div className="bg-white rounded-2xl p-8 md:p-10 border border-greigeDark/30 mb-12 animate-[fadeInUp_0.6s_ease-out_0.3s_both]">
<h2 className="font-serif text-lg md:text-xl font-semibold text-warmBrown mb-6">
Что дальше
</h2>
<div className="space-y-5">
{steps.map((step, i) => (
<div
key={i}
className="flex items-start gap-4 text-left group"
>
<span className="w-8 h-8 rounded-full bg-greige/60 flex items-center justify-center flex-shrink-0 font-serif text-sm font-semibold text-taupe mt-0.5 group-hover:bg-greige transition-colors duration-300">
{i + 1}
</span>
<p className="text-sm md:text-base font-sans font-normal text-softBrown leading-relaxed">
{step}
</p>
</div>
))}
</div>
</div>
{/* Socials */}
<div className="mb-12 animate-[fadeInUp_0.6s_ease-out_0.4s_both]">
<p className="text-xs md:text-sm font-sans font-light text-mutedBrown/70 italic mb-5">
Пока ждёте загляните в мои социальные сети
</p>
<div className="flex items-center justify-center gap-3 md:gap-4">
{socials.map((s, i) => (
<a
key={i}
href={s.href}
target="_blank"
rel="noopener noreferrer"
aria-label={s.label}
className="w-11 h-11 flex items-center justify-center rounded-full bg-white border border-greigeDark/30 text-warmBrown/70 hover:text-warmBrown hover:bg-greige/40 hover:border-transparent hover:-translate-y-0.5 transition-all duration-300"
>
<i className={`${s.icon} text-lg`}></i>
</a>
))}
</div>
</div>
{/* CTA */}
<button
onClick={() => navigate("/")}
className="inline-flex items-center gap-2.5 px-8 py-4 bg-taupe hover:bg-taupeDark text-white font-sans text-sm font-medium uppercase tracking-[0.1em] rounded-full transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg animate-[fadeInUp_0.6s_ease-out_0.5s_both]"
>
<i className="ri-arrow-left-line text-sm"></i>
Вернуться на главную
</button>
</div>
</main>
{/* Footer */}
<footer className="w-full px-6 md:px-10 lg:px-16 py-8 border-t border-greigeDark/30">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-xs font-sans font-light text-mutedBrown/60">
© 2026 Ирина. Все права защищены.
</p>
<p className="text-xs font-sans font-light text-mutedBrown/60">
Практикующий психолог
</p>
</div>
</footer>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { createBrowserRouter } from 'react-router-dom';
import HomePage from '../pages/home/page';
import ThanksPage from '../pages/thanks/page';
import NotFound from '../pages/NotFound';
export const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/thanks',
element: <ThanksPage />,
},
{
path: '*',
element: <NotFound />,
},
]);
+1
View File
@@ -0,0 +1 @@
export { router } from './config';
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
cream: '#FAF8F5',
warmBrown: '#3D3530',
greige: '#E8E4DE',
greigeDark: '#D5CFC8',
taupe: '#8C7E6B',
taupeDark: '#6B5F4F',
mutedBrown: '#9A9088',
softBrown: '#6B6560',
powder: '#E8E0EC',
lavender: '#D5CFE0',
},
fontFamily: {
serif: ['Cormorant Garamond', 'serif'],
sans: ['Manrope', 'sans-serif'],
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
base: './',
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})