Initial commit: restructure project with Docker Compose setup
This commit is contained in:
+27
@@ -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/
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Generated
+1853
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# URL backend API (для локальной разработки без Docker)
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Для production сборки в Docker используется /api (через nginx proxy)
|
||||||
@@ -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;"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2616
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const ru = {
|
||||||
|
nav: {
|
||||||
|
about: 'Обо мне',
|
||||||
|
approach: 'Подход',
|
||||||
|
process: 'Как работаем',
|
||||||
|
issues: 'Запросы',
|
||||||
|
pricing: 'Стоимость',
|
||||||
|
contact: 'Записаться',
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
quote: 'Терапия — не про поиск «лучшей версии себя». Это про смелость наконец-то увидеть и стать собой.',
|
||||||
|
subtitle: 'Здесь безопасное пространство для вашей внутренней работы.',
|
||||||
|
cta: 'Записаться на бесплатное знакомство',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
namePlaceholder: 'Ваше имя',
|
||||||
|
phonePlaceholder: 'Номер телефона',
|
||||||
|
submit: 'Записаться',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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">
|
||||||
|
«Терапия — не про поиск «лучшей версии себя». Это про смелость наконец-то увидеть и стать собой.»
|
||||||
|
</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">60–80 мин</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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { router } from './config';
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user