first commit
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
1
frontend/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
20
|
||||
26
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Используем официальный образ Bun через зеркало
|
||||
FROM dockerhub.timeweb.cloud/oven/bun:alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем файлы зависимостей
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install
|
||||
|
||||
# Копируем проект и собираем
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# Финальный образ
|
||||
FROM dockerhub.timeweb.cloud/oven/bun:alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем билд из предыдущего этапа
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
|
||||
# Указываем порт
|
||||
EXPOSE 4321
|
||||
|
||||
# Запуск через Bun
|
||||
CMD ["bun", "./dist/server/entry.mjs"]
|
||||
61
frontend/QWEN.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Правила взаимодействия с Qwen Code Assistant
|
||||
|
||||
## Основные принципы
|
||||
|
||||
1. **Изменения в коде возможны только с явного разрешения пользователя**
|
||||
- Перед внесением любых изменений в файлы ассистент должен получить подтверждение от пользователя
|
||||
- Все изменения должны быть предварительно объяснены пользователю
|
||||
- Перед решением конкретной задачи всегда составлять план
|
||||
- После внесения изменений в код - проводить проверку - только после этого приступать к дальнейшему решению задачи
|
||||
|
||||
2. **Прозрачность действий**
|
||||
- Ассистент должен объяснить, какие изменения планируется внести
|
||||
- Необходимо указать, в какие файлы будут внесены изменения
|
||||
- Следует объяснить последствия предполагаемых изменений
|
||||
|
||||
3. **Безопасность кода**
|
||||
- Все изменения должны проходить проверку на безопасность
|
||||
- Не должны вноситься изменения, которые могут повредить функциональность приложения
|
||||
- Рекомендуется создание резервных копий при значительных изменениях
|
||||
|
||||
4. **Согласование архитектурных решений**
|
||||
- При внесении изменений, затрагивающих архитектуру приложения, необходима дискуссия с пользователем
|
||||
- Предложения по улучшению архитектуры должны обсуждаться до реализации
|
||||
|
||||
5. **Работа с разными типами проектов**
|
||||
- Уважать существующую архитектуру и стиль кода проекта
|
||||
- Следовать установленным в проекте принципам и паттернам
|
||||
|
||||
6. **Использование Bun**
|
||||
- Все команды должны выполняться с использованием Bun (bun install, bun dev, bun build и т.д.)
|
||||
- При создании скриптов в package.json, они должны быть совместимы с Bun
|
||||
|
||||
7. **Язык общения**
|
||||
- Всё общение с пользователем происходит на русском языке
|
||||
|
||||
8. **Проверка изменений**
|
||||
- После внесения изменений в код не требуется запускать сервер разработки для проверки
|
||||
- Пользователь самостоятельно запускает сервер и проверяет изменения
|
||||
|
||||
9. **Проверка типов данных**
|
||||
- Проверять проект на ошибки типизации через команду `bun run tsc --noEmit -p frontend/tsconfig.json`
|
||||
- В проекте не должно быть типов any
|
||||
- Все интерфейсы компонентов прописывать в файле globalInterfaces.ts
|
||||
- При работе с PocketBase использовать актуальные сигнатуры методов из файла `D:\Verstka\production\astro_minivan\frontend\node_modules\pocketbase\dist\pocketbase.es.d.ts`
|
||||
|
||||
10. **Плагин @astrojs/sitemap**
|
||||
- Обязательно к установке в проект пакета @astrojs/sitemap
|
||||
- Обязательно к созданию в проекте файла .nvmrc
|
||||
|
||||
11. **Замена хоста при развертывании проекта**
|
||||
- Обязательно нужно изменить http://localhost:3000/ в шаблонах писем на реальный
|
||||
|
||||
|
||||
|
||||
## Qwen Added Memories
|
||||
- URL документации Astro: https://docs.astro.build/en/getting-started/
|
||||
- URL документации PocketBase: https://pocketbase.io/docs/
|
||||
- URL документации SolidJS: https://docs.solidjs.com/solid-start/getting-started
|
||||
- URL документации astro-icons: https://www.astroicon.dev/getting-started/
|
||||
- URL документации PayloadCMS: https://payloadcms.com/docs/getting-started/what-is-payload
|
||||
- URL документации Prisma ORM и Astro: https://www.prisma.io/docs/ai/prompts/astro
|
||||
46
frontend/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
bun create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `bun install` | Installs dependencies |
|
||||
| `bun dev` | Starts local dev server at `localhost:4321` |
|
||||
| `bun build` | Build your production site to `./dist/` |
|
||||
| `bun preview` | Preview your build locally, before deploying |
|
||||
| `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `bun astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
20
frontend/astro.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { default as astroIcon } from 'astro-icon';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import node from '@astrojs/node';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import solidJs from '@astrojs/solid-js';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://advokat086.ru',
|
||||
integrations: [astroIcon(), mdx(), sitemap(), solidJs()],
|
||||
prefetch: true,
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
});
|
||||
1217
frontend/bun.lock
Normal file
28
frontend/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.13",
|
||||
"@astrojs/node": "9.5.4",
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
"@astrojs/solid-js": "^5.1.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "5.18.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"gsap": "^3.14.2",
|
||||
"pocketbase": "^0.26.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^25.0.9",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/images/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/images/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
frontend/public/images/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 98 KiB |
21
frontend/public/images/favicon/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
frontend/public/images/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
frontend/public/images/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
frontend/public/images/gitImage.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/public/images/hero/heroImg.avif
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/public/images/hero/heroImg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/images/posts/author/face_02.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
0
frontend/public/robots.txt
Normal file
BIN
frontend/src/assets/images/about/адвокат_Сургута.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/src/assets/images/home/whyus/WhyUs.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
121
frontend/src/components/about/AboutHero.astro
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import lawyerImage from '@assets/images/about/адвокат_Сургута.jpg';
|
||||
import { LAWYER_NAME } from '@constants/constants.ts';
|
||||
import { getAboutHeroStats, getYearsOfPractice, getClientDeclension, getYearDeclension } from '@utils/stats.utils.ts';
|
||||
|
||||
const dynamicStats = getAboutHeroStats();
|
||||
|
||||
const content = {
|
||||
quote: "Моя миссия — не просто представлять интересы в суде, а обеспечивать реальную защиту прав и свобод каждого клиента в Сургуте и ХМАО - Югре. В законе нет мелочей, есть только возможности.",
|
||||
description: `С 2005 года я специализируюсь на сложных уголовных, административных и гражданских делах. Мой опыт позволяет находить нестандартные решения там, где другие видят тупик.`,
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative py-20 md:py-32 overflow-hidden bg-[#F8F9FA] -mx-4 md:mx-0">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-[var(--color-blue-primary)] opacity-5 rounded-full blur-3xl translate-x-1/3 -translate-y-1/3"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[500px] h-[500px] bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl -translate-x-1/3 translate-y-1/3"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<!-- ЛЕВАЯ КОЛОНКА: Фото с эффектами -->
|
||||
<div class="relative w-full max-w-md mx-auto lg:max-w-none">
|
||||
<!-- Декоративная рамка с градиентом -->
|
||||
<div class="absolute -inset-4 bg-gradient-to-br from-[var(--color-gold)]/20 to-[var(--color-blue-primary)]/20 rounded-3xl blur-2xl opacity-50"></div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Главное фото с glassmorphism рамкой -->
|
||||
<div class="relative bg-white/80 backdrop-blur p-3 rounded-2xl shadow-2xl border border-white/50">
|
||||
<div class="aspect-[4/5] rounded-xl overflow-hidden bg-gray-900">
|
||||
<Image
|
||||
src={lawyerImage}
|
||||
alt={LAWYER_NAME.full}
|
||||
class="w-full h-full object-cover transform hover:scale-105 transition-transform duration-700"
|
||||
loading="eager"
|
||||
/>
|
||||
<!-- Градиентный overlay снизу -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Плавающий бейдж опыта -->
|
||||
<div class="absolute -bottom-6 -right-6 bg-white/95 backdrop-blur-xl p-6 rounded-2xl shadow-2xl border border-[var(--color-gold)]/20 transform hover:scale-105 transition-transform duration-300">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-xl flex items-center justify-center shadow-lg shadow-[var(--color-gold)]/30">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-3xl font-extrabold text-gray-900 leading-none">{getYearsOfPractice()}+</span>
|
||||
<span class="text-xs font-bold text-[var(--color-gold)] uppercase tracking-wider">лет практики</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративный элемент с quote -->
|
||||
<div class="absolute -top-4 -left-4 w-20 h-20 bg-[var(--color-blue-primary)] rounded-2xl flex items-center justify-center shadow-xl transform -rotate-6 hover:rotate-0 transition-transform duration-300 hidden lg:flex">
|
||||
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ПРАВАЯ КОЛОНКА: Контент -->
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-blue-primary)]/10 rounded-full border border-[var(--color-blue-primary)]/20 mb-6">
|
||||
<span class="w-2 h-2 bg-[var(--color-blue-primary)] rounded-full animate-pulse"></span>
|
||||
<span class="text-[var(--color-blue-primary)] text-sm font-bold uppercase tracking-wider">{LAWYER_NAME.position}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 leading-tight mb-6">
|
||||
{LAWYER_NAME.first} <span class="text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-blue-primary)] to-blue-600">{LAWYER_NAME.middle}</span><br />
|
||||
{LAWYER_NAME.last}
|
||||
</h1>
|
||||
|
||||
<div class="w-24 h-1.5 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full mb-8 mx-auto lg:mx-0"></div>
|
||||
|
||||
<blockquote class="relative text-md md:text-xl text-gray-700 font-light italic leading-relaxed mb-8 pl-0 lg:pl-8 border-l-0 lg:border-l-4 border-[var(--color-gold)]">
|
||||
<span class="absolute -top-4 -left-2 text-6xl text-[var(--color-gold)]/20 font-serif hidden lg:block">"</span>
|
||||
{content.quote}
|
||||
</blockquote>
|
||||
|
||||
<p class="text-gray-600 text-lg leading-relaxed mb-10 max-w-xl mx-auto lg:mx-0">
|
||||
{content.description}
|
||||
</p>
|
||||
|
||||
<!-- Статистика в glassmorphism карточках -->
|
||||
<div class="flex flex-wrap justify-center lg:justify-start gap-4">
|
||||
<div class="group bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50 hover:shadow-xl hover:border-[var(--color-blue-primary)]/30 transition-all duration-300 min-w-[160px]">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 bg-[var(--color-blue-primary)]/10 rounded-xl flex items-center justify-center group-hover:bg-[var(--color-blue-primary)] transition-colors duration-300">
|
||||
<svg class="w-5 h-5 text-[var(--color-blue-primary)] group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-3xl font-bold text-gray-900">{dynamicStats[0].number}{dynamicStats[0].suffix}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Успешных дел</span>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50 hover:shadow-xl hover:border-[var(--color-gold)]/30 transition-all duration-300 min-w-[160px]">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 bg-[var(--color-gold)]/10 rounded-xl flex items-center justify-center group-hover:bg-[var(--color-gold)] transition-colors duration-300">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)] group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-3xl font-bold text-gray-900">{dynamicStats[1].number}{dynamicStats[1].suffix}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Довольных клиентов</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
173
frontend/src/components/about/Achievements.astro
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
---
|
||||
import { getDynamicStats, getYearDeclension, getClientDeclension, getYearsOfPractice } from '@utils/stats.utils.ts';
|
||||
import { CONTACT_CONSTANTS } from '@constants/constants.ts';
|
||||
|
||||
const dynamicStats = getDynamicStats();
|
||||
const yearsOfPractice = getYearsOfPractice();
|
||||
|
||||
const achievements = [
|
||||
{
|
||||
number: yearsOfPractice,
|
||||
suffix: '',
|
||||
text: 'Лет практики',
|
||||
type: 'years',
|
||||
icon: 'calendar'
|
||||
},
|
||||
{
|
||||
number: dynamicStats[1].number,
|
||||
suffix: dynamicStats[1].suffix,
|
||||
text: 'Успешных дел',
|
||||
type: 'cases',
|
||||
icon: 'briefcase'
|
||||
},
|
||||
{
|
||||
number: dynamicStats[2].number,
|
||||
suffix: dynamicStats[2].suffix,
|
||||
text: 'довольных клиентов',
|
||||
type: 'clients',
|
||||
icon: 'users'
|
||||
},
|
||||
{
|
||||
number: "24/7",
|
||||
suffix: '',
|
||||
text: 'На связи',
|
||||
type: 'support',
|
||||
icon: 'clock'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const iconPaths = {
|
||||
calendar: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
briefcase: "M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
|
||||
users: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
clock: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
} as const;
|
||||
|
||||
type StatType = typeof achievements[number]['type'];
|
||||
|
||||
const getStatText = (stat: typeof achievements[number]): string => {
|
||||
const texts: Record<StatType, string> = {
|
||||
years: `${stat.number} ${getYearDeclension(stat.number)} практики`,
|
||||
cases: 'Успешных дел',
|
||||
clients: `${stat.number} ${getClientDeclension(stat.number)}`,
|
||||
support: 'Поддержка клиентов'
|
||||
};
|
||||
return texts[stat.type];
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative py-20 bg-gradient-to-b from-gray-50 to-white overflow-hidden -mx-4 md:mx-0">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
Ключевые достижения в <span class="text-[var(--color-blue-primary)]">цифрах</span>
|
||||
</h2>
|
||||
<div class="w-20 h-1 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] mx-auto rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка достижений -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
||||
{achievements.map((stat) => (
|
||||
<div class="group bg-white/80 backdrop-blur-xl rounded-2xl p-6 md:p-8 shadow-lg border border-white/50 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 hover:-translate-y-1 transition-all duration-500 text-center relative overflow-hidden">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Иконка -->
|
||||
<div class="w-12 h-12 mx-auto bg-[var(--color-blue-primary)]/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-[var(--color-blue-primary)] transition-colors duration-300">
|
||||
<svg class="w-6 h-6 text-[var(--color-blue-primary)] group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d={iconPaths[stat.icon]}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Число -->
|
||||
<div class="flex items-baseline justify-center gap-1 mb-2">
|
||||
{stat.type !== 'support' ? (
|
||||
<span class="stat-counter text-4xl md:text-5xl font-extrabold text-gray-900" data-target={stat.number}>
|
||||
0
|
||||
</span>
|
||||
) : (
|
||||
<span class="text-3xl md:text-4xl font-extrabold text-[var(--color-blue-primary)]">{stat.number}</span>
|
||||
)}
|
||||
<span class="text-2xl font-bold text-[var(--color-blue-primary)]">{stat.suffix}</span>
|
||||
</div>
|
||||
|
||||
<!-- Текст -->
|
||||
<span class="text-xs md:text-sm font-bold text-gray-500 uppercase tracking-wider">
|
||||
{getStatText(stat)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- CTA Блок -->
|
||||
<div class="relative bg-gradient-to-r from-[var(--color-blue-primary)] to-blue-700 rounded-3xl p-8 md:p-12 text-center overflow-hidden shadow-2xl shadow-blue-500/30">
|
||||
<div class="relative z-10 max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl md:text-3xl font-bold text-white mb-4">
|
||||
Готовы обсудить вашу ситуацию?
|
||||
</h3>
|
||||
<p class="text-blue-100 mb-8">
|
||||
Получите профессиональную консультацию адвоката уже сегодня
|
||||
</p>
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="inline-block px-8 py-4 bg-white text-[var(--color-blue-primary)] font-bold rounded-xl hover:bg-gray-100 hover:scale-105 hover:shadow-xl transition-all duration-300 shadow-lg cursor-pointer"
|
||||
>
|
||||
Получить консультацию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const initStatsAnimation = () => {
|
||||
const counters = document.querySelectorAll<HTMLElement>('.stat-counter');
|
||||
if (!counters.length) return;
|
||||
|
||||
const speed = 2000;
|
||||
|
||||
const animate = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) return;
|
||||
|
||||
const counter = entry.target;
|
||||
const target = parseInt(counter.dataset.target || '0', 10);
|
||||
let startTime: number | null = null;
|
||||
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / speed, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
counter.textContent = Math.floor(ease * target).toString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
counter.textContent = target.toString();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(step);
|
||||
observer.unobserve(counter);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(animate, {
|
||||
threshold: 0.5,
|
||||
rootMargin: '0px'
|
||||
});
|
||||
|
||||
counters.forEach(counter => observer.observe(counter));
|
||||
};
|
||||
|
||||
// Запускаем анимацию
|
||||
initStatsAnimation();
|
||||
|
||||
// Для Astro View Transitions
|
||||
document.addEventListener('astro:after-swap', initStatsAnimation);
|
||||
document.addEventListener('astro:page-load', initStatsAnimation);
|
||||
</script>
|
||||
147
frontend/src/components/about/Education.astro
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
---
|
||||
const education = [
|
||||
{
|
||||
years: "1997 — 2003",
|
||||
title: "Тюменский государственный университет",
|
||||
description: "Юридический факультет, Специальность «Юриспруденция» (Диплом с отличием)"
|
||||
},
|
||||
{
|
||||
years: "2010 — 2012",
|
||||
title: "Магистратура МГЮА им. О.Е. Кутафина",
|
||||
description: "Специализация: Уголовное право и криминология"
|
||||
},
|
||||
{
|
||||
years: "2015",
|
||||
title: "Повышение квалификации",
|
||||
description: "Курс «Защита прав в Европейском Суде по правам человека» (Страсбург)"
|
||||
}
|
||||
];
|
||||
|
||||
const additional = [
|
||||
"Ежегодные семинары Федеральной палаты адвокатов РФ",
|
||||
"Спецкурс по финансовым и налоговым преступлениям",
|
||||
"Тренинги по судебному ораторскому искусству",
|
||||
"Сертифицированный медиатор в гражданских спорах"
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden bg-white -mx-4 md:mx-0">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-1/2 left-0 w-96 h-96 bg-[var(--color-blue-primary)] opacity-5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="flex items-center gap-4 mb-16 justify-center lg:justify-start">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-[var(--color-blue-primary)] to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900">
|
||||
Образование и <span class="text-[var(--color-blue-primary)]">квалификация</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||
<!-- Таймлайн -->
|
||||
<div class="relative">
|
||||
<!-- Линия -->
|
||||
<div class="absolute left-6 top-0 bottom-0 w-0.5 bg-gradient-to-b from-[var(--color-blue-primary)] via-[var(--color-gold)] to-transparent"></div>
|
||||
|
||||
<div class="space-y-10">
|
||||
{education.map((item, index) => (
|
||||
<div class="relative pl-16 group timeline-item opacity-0 translate-y-8 transition-all duration-700" style={`transition-delay: ${index * 200}ms`}>
|
||||
<!-- Точка -->
|
||||
<div class="absolute left-0 top-0 w-12 h-12 bg-white rounded-full border-4 border-[var(--color-blue-primary)] shadow-lg flex items-center justify-center z-10 group-hover:scale-110 transition-transform duration-300">
|
||||
<span class="text-[var(--color-blue-primary)] font-bold text-sm">{index + 1}</span>
|
||||
</div>
|
||||
|
||||
<!-- Карточка -->
|
||||
<div class="bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl hover:border-[var(--color-blue-primary)]/20 transition-all duration-300 group-hover:-translate-y-1">
|
||||
<span class="inline-block px-3 py-1 bg-[var(--color-gold)]/10 text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider rounded-full mb-3">
|
||||
{item.years}
|
||||
</span>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2 group-hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка: Дополнительная подготовка -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-navy)] to-blue-900 rounded-3xl transform rotate-1 opacity-10"></div>
|
||||
<div class="relative bg-gradient-to-br from-[var(--color-navy)] to-[#1a1f3d] rounded-3xl p-8 md:p-10 shadow-2xl border border-white/10 overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-white mb-8 flex items-center gap-3">
|
||||
<span class="w-10 h-10 bg-[var(--color-gold)]/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
</span>
|
||||
Дополнительная подготовка
|
||||
</h3>
|
||||
|
||||
<ul class="space-y-5">
|
||||
{additional.map((text, index) => (
|
||||
<li class="flex items-start gap-4 group additional-item opacity-0 translate-x-8 transition-all duration-500" style={`transition-delay: ${index * 100}ms`}>
|
||||
<div class="flex-shrink-0 w-6 h-6 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full flex items-center justify-center mt-0.5 shadow-lg shadow-[var(--color-gold)]/30 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-3.5 h-3.5 text-[var(--color-navy)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-gray-300 leading-relaxed group-hover:text-white transition-colors">
|
||||
{text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-white/10">
|
||||
<div class="flex items-center gap-3 text-[var(--color-gold)]">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-bold uppercase tracking-wider">Постоянное совершенствование</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.2
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.remove('opacity-0', 'translate-y-8', 'translate-x-8');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.timeline-item, .additional-item').forEach(item => {
|
||||
observer.observe(item);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
80
frontend/src/components/about/ProfCredo.astro
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
const features = [
|
||||
{
|
||||
title: "Тщательный анализ",
|
||||
description: "Детальное изучение каждого документа и обстоятельства дела для выявления всех возможных рисков и преимуществ.",
|
||||
icon: "search"
|
||||
},
|
||||
{
|
||||
title: "Защита интересов",
|
||||
description: "Бескомпромиссная позиция в суде и на переговорах. Ваша безопасность и правовая чистота — мой главный приоритет.",
|
||||
icon: "shield"
|
||||
},
|
||||
{
|
||||
title: "Честный диалог",
|
||||
description: "Прямая коммуникация о перспективах дела. Без ложных надежд, только факты и профессиональная оценка.",
|
||||
icon: "message"
|
||||
}
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
search: "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607z",
|
||||
shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
|
||||
message: "M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative py-24 overflow-hidden bg-[#0a0e27] -mx-4 md:mx-0">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-[var(--color-blue-primary)] opacity-10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl"></div>
|
||||
<!-- Сетка -->
|
||||
<div class="absolute inset-0 opacity-5" style="background-image: linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-16 max-w-3xl mx-auto">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-sm font-bold uppercase tracking-wider mb-6">
|
||||
Принципы работы
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-white mb-6 tracking-tight">
|
||||
Профессиональное <span class="text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-gold)] to-amber-400">кредо</span>
|
||||
</h2>
|
||||
<p class="text-gray-400 text-lg leading-relaxed">
|
||||
Мой подход базируется на трех столпах: глубокая аналитика, абсолютная конфиденциальность и ориентация на результат. Я не даю пустых обещаний, я предоставляю юридическую защиту высшего класса.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Карточки -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<div class="group relative bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-8 hover:bg-white/10 hover:border-[var(--color-gold)]/30 transition-all duration-500 hover:-translate-y-2">
|
||||
<!-- Номер -->
|
||||
<div class="absolute top-6 right-6 text-6xl font-bold text-white/5 group-hover:text-[var(--color-gold)]/10 transition-colors">
|
||||
0{index + 1}
|
||||
</div>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="relative w-14 h-14 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-xl flex items-center justify-center mb-6 shadow-lg shadow-[var(--color-gold)]/20 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-7 h-7 text-[var(--color-navy)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[feature.icon]}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-white mb-3 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{feature.title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 leading-relaxed text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
<!-- Линия подчеркивания при hover -->
|
||||
<div class="absolute bottom-0 left-0 w-0 h-1 bg-gradient-to-r from-[var(--color-gold)] to-transparent group-hover:w-full transition-all duration-500 rounded-b-2xl"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
95
frontend/src/components/about/Specializations.astro
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
const specializations = [
|
||||
{
|
||||
title: "Уголовное право",
|
||||
description: "Защита по уголовным делам, досудебное урегулирование, представительство в судах всех инстанций",
|
||||
icon: "scale"
|
||||
},
|
||||
{
|
||||
title: "Гражданское право",
|
||||
description: "Споры по договорам, имущественные споры, наследственные дела, жилищные вопросы",
|
||||
icon: "building"
|
||||
},
|
||||
{
|
||||
title: "Административное право",
|
||||
description: "Обжалование постановлений, защита прав водителей, дела об административных правонарушениях",
|
||||
icon: "clipboard"
|
||||
},
|
||||
{
|
||||
title: "Семейное право",
|
||||
description: "Разводы, раздел имущества, споры о детях, алименты",
|
||||
icon: "users"
|
||||
},
|
||||
{
|
||||
title: "Жилищное право",
|
||||
description: "Приватизация, переселение, незаконная перепланировка, выселение",
|
||||
icon: "home"
|
||||
},
|
||||
{
|
||||
title: "Земельное право",
|
||||
description: "Оформление земельных участков, споры о границах, изъятие земли",
|
||||
icon: "territory"
|
||||
}
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
scale: "M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3",
|
||||
building: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4",
|
||||
clipboard: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01",
|
||||
users: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
home: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
territory: "M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative py-20 bg-[#F8F9FA] overflow-hidden -mx-4 md:mx-0">
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute bottom-0 left-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-blue-primary)]/10 text-[var(--color-blue-primary)] text-sm font-bold uppercase tracking-wider rounded-full mb-4">
|
||||
Экспертиза
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
Мои <span class="text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-blue-primary)] to-blue-600">специализации</span>
|
||||
</h2>
|
||||
<div class="w-24 h-1.5 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] mx-auto rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{specializations.map((item, index) => (
|
||||
<div class="group bg-white/80 backdrop-blur-xl p-8 rounded-2xl shadow-lg border border-white/50 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 hover:-translate-y-2 transition-all duration-500 relative overflow-hidden">
|
||||
<!-- Декоративный фон при hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Иконка -->
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-[var(--color-blue-primary)] to-blue-600 rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-blue-500/30 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[item.icon]}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3 group-hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 leading-relaxed text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
<!-- Стрелка при hover -->
|
||||
<div class="mt-6 flex items-center text-[var(--color-blue-primary)] font-semibold text-sm opacity-0 transform translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300">
|
||||
Подробнее
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
529
frontend/src/components/auth/PasswordResetForm.astro
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
---
|
||||
import Button from "../base/Button.astro";
|
||||
|
||||
// Иконки
|
||||
const emailIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>`;
|
||||
|
||||
const lockIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>`;
|
||||
|
||||
const checkIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7"/></svg>`;
|
||||
|
||||
const arrowLeftIcon = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>`;
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
// Режим: 'request' - запрос сброса, 'reset' - установка нового пароля
|
||||
mode?: "request" | "reset";
|
||||
token?: string; // Токен из URL для режима reset
|
||||
}
|
||||
|
||||
const { mode = "request", token = "" } = Astro.props;
|
||||
|
||||
const isResetMode = mode === "reset";
|
||||
const title = isResetMode ? "Новый пароль" : "Восстановление пароля";
|
||||
const subtitle = isResetMode
|
||||
? "Придумайте новый пароль для вашего аккаунта"
|
||||
: "Введите email, и мы отправим вам ссылку для сброса пароля";
|
||||
---
|
||||
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<!-- Назад к входу -->
|
||||
<a
|
||||
href="/auth/login"
|
||||
class="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gold transition-colors mb-8"
|
||||
>
|
||||
<Fragment set:html={arrowLeftIcon} />
|
||||
<span>Вернуться к входу</span>
|
||||
</a>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{title}</h1>
|
||||
<p class="text-sm text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<!-- Форма запроса сброса -->
|
||||
{!isResetMode && (
|
||||
<form class="space-y-6" id="forgot-form" novalidate>
|
||||
<!-- Honeypot -->
|
||||
<div class="honeypot-field" aria-hidden="true">
|
||||
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="relative group">
|
||||
<label for="email" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
|
||||
<Fragment set:html={emailIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxlength="254"
|
||||
pattern="[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите корректный email адрес
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<Button
|
||||
type="submit"
|
||||
text="Отправить ссылку"
|
||||
variant="primary-white-text"
|
||||
size="md"
|
||||
className="w-full!"
|
||||
id="submit-btn"
|
||||
disabled
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<!-- Форма установки нового пароля -->
|
||||
{isResetMode && (
|
||||
<form class="space-y-6" id="reset-form" novalidate data-token={token}>
|
||||
<!-- Honeypot -->
|
||||
<div class="honeypot-field" aria-hidden="true">
|
||||
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<!-- Новый пароль -->
|
||||
<div class="relative group">
|
||||
<label for="password" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Новый пароль <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
|
||||
<Fragment set:html={lockIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-password"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Показать пароль"
|
||||
>
|
||||
<svg id="eye-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<svg id="eye-off-icon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Пароль должен содержать хотя бы одну букву и одну цифру
|
||||
</span>
|
||||
<p class="text-xs text-gray-400 mt-1">От 8 до 12 символов, буква + цифра</p>
|
||||
</div>
|
||||
|
||||
<!-- Подтверждение пароля -->
|
||||
<div class="relative group">
|
||||
<label for="confirm-password" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Повторите пароль <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
|
||||
<Fragment set:html={checkIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-confirm-password"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Показать пароль"
|
||||
>
|
||||
<svg id="eye-icon-confirm" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<svg id="eye-off-icon-confirm" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Пароли не совпадают
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<Button
|
||||
type="submit"
|
||||
text="Сохранить пароль"
|
||||
variant="primary-white-text"
|
||||
size="md"
|
||||
className="w-full!"
|
||||
id="submit-btn"
|
||||
disabled
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.honeypot-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Определяем режим по наличию формы
|
||||
const isResetMode = !!document.getElementById('reset-form');
|
||||
const form = document.getElementById(isResetMode ? 'reset-form' : 'forgot-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const honeypotField = document.getElementById('website');
|
||||
|
||||
// Логирование
|
||||
function log(message, data) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[PASSWORD_RESET_FORM][${timestamp}] ${message}`, data || '');
|
||||
}
|
||||
|
||||
function logError(message, data) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.error(`[PASSWORD_RESET_FORM][${timestamp}] ERROR: ${message}`, data || '');
|
||||
}
|
||||
|
||||
const touchedFields = new Set();
|
||||
|
||||
// Утилита для ошибок
|
||||
function showFieldError(input, show, message = "") {
|
||||
const group = input.closest('.group');
|
||||
const errorEl = group?.querySelector('.error-message');
|
||||
|
||||
if (errorEl) {
|
||||
if (show) {
|
||||
if (message) errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
input.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500/20');
|
||||
input.classList.remove('focus:border-gold', 'focus:ring-gold/20');
|
||||
} else {
|
||||
errorEl.classList.add('hidden');
|
||||
input.classList.remove('border-red-500', 'focus:border-red-500', 'focus:ring-red-500/20');
|
||||
input.classList.add('focus:border-gold', 'focus:ring-gold/20');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация email
|
||||
function validateEmail(showError = false) {
|
||||
const emailInput = document.getElementById('email');
|
||||
if (!emailInput) return true;
|
||||
|
||||
const value = emailInput.value.trim();
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
let isValid = true;
|
||||
|
||||
if (!value || !emailPattern.test(value) || value.length > 254) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has('email')) {
|
||||
showFieldError(emailInput, !isValid && value.length > 0);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация пароля
|
||||
function validatePassword(showError = false) {
|
||||
const passwordInput = document.getElementById('password');
|
||||
if (!passwordInput) return true;
|
||||
|
||||
const value = passwordInput.value;
|
||||
const minLength = 8;
|
||||
const maxLength = 12;
|
||||
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+$/;
|
||||
let isValid = true;
|
||||
|
||||
if (!value || value.length < minLength || value.length > maxLength) {
|
||||
isValid = false;
|
||||
} else if (!passwordRegex.test(value)) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has('password')) {
|
||||
showFieldError(passwordInput, !isValid && value.length > 0);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация подтверждения пароля
|
||||
function validateConfirmPassword(showError = false) {
|
||||
const confirmInput = document.getElementById('confirm-password');
|
||||
const passwordInput = document.getElementById('password');
|
||||
if (!confirmInput || !passwordInput) return true;
|
||||
|
||||
const value = confirmInput.value;
|
||||
const passwordValue = passwordInput.value;
|
||||
let isValid = true;
|
||||
|
||||
if (!value || value !== passwordValue) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has('confirmPassword')) {
|
||||
showFieldError(confirmInput, !isValid && value.length > 0,
|
||||
value !== passwordValue ? 'Пароли не совпадают' : '');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Проверка всей формы
|
||||
function checkFormValidity() {
|
||||
const isHoneypotEmpty = !honeypotField?.value;
|
||||
|
||||
if (isResetMode) {
|
||||
const isPasswordValid = validatePassword(false);
|
||||
const isConfirmValid = validateConfirmPassword(false);
|
||||
submitBtn.disabled = !(isPasswordValid && isConfirmValid && isHoneypotEmpty);
|
||||
return isPasswordValid && isConfirmValid && isHoneypotEmpty;
|
||||
} else {
|
||||
const isEmailValid = validateEmail(false);
|
||||
submitBtn.disabled = !(isEmailValid && isHoneypotEmpty);
|
||||
return isEmailValid && isHoneypotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// Показать все ошибки
|
||||
function showAllErrors() {
|
||||
if (isResetMode) {
|
||||
touchedFields.add('password');
|
||||
touchedFields.add('confirmPassword');
|
||||
const isPasswordValid = validatePassword(true);
|
||||
const isConfirmValid = validateConfirmPassword(true);
|
||||
return isPasswordValid && isConfirmValid;
|
||||
} else {
|
||||
touchedFields.add('email');
|
||||
return validateEmail(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка переключателя пароля
|
||||
function setupPasswordToggle(btnId, inputId, eyeId, eyeOffId) {
|
||||
const btn = document.getElementById(btnId);
|
||||
const input = document.getElementById(inputId);
|
||||
const eyeIcon = document.getElementById(eyeId);
|
||||
const eyeOffIcon = document.getElementById(eyeOffId);
|
||||
|
||||
btn?.addEventListener('click', () => {
|
||||
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
|
||||
input.setAttribute('type', type);
|
||||
|
||||
if (type === 'text') {
|
||||
eyeIcon?.classList.add('hidden');
|
||||
eyeOffIcon?.classList.remove('hidden');
|
||||
btn.setAttribute('aria-label', 'Скрыть пароль');
|
||||
} else {
|
||||
eyeIcon?.classList.remove('hidden');
|
||||
eyeOffIcon?.classList.add('hidden');
|
||||
btn.setAttribute('aria-label', 'Показать пароль');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Режим запроса сброса
|
||||
if (!isResetMode) {
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
// Ограничение ввода email
|
||||
emailInput?.addEventListener('keypress', (e) => {
|
||||
if (!/[a-zA-Z0-9@._%+\-]/.test(e.key) &&
|
||||
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
emailInput?.addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.replace(/\s/g, '');
|
||||
validateEmail();
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
emailInput?.addEventListener('focus', () => touchedFields.add('email'));
|
||||
emailInput?.addEventListener('blur', () => {
|
||||
touchedFields.add('email');
|
||||
if (emailInput.value.length > 0) validateEmail(true);
|
||||
});
|
||||
|
||||
// Отправка формы запроса
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (honeypotField?.value) {
|
||||
console.log('Bot detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showAllErrors()) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.textContent = 'Отправка...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const email = formData.get('email');
|
||||
|
||||
log(`Запрос сброса пароля для: ${email}`);
|
||||
|
||||
// Редирект на страницу успеха с email
|
||||
log('Перенаправление на страницу подтверждения отправки');
|
||||
window.location.href = `/auth/forgot-password-sent?email=${encodeURIComponent(email)}`;
|
||||
|
||||
} catch (error) {
|
||||
logError('❌ Ошибка сброса пароля:', error);
|
||||
alert(error.message || 'Ошибка отправки. Попробуйте позже.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Режим установки нового пароля
|
||||
if (isResetMode) {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const confirmInput = document.getElementById('confirm-password');
|
||||
const token = form?.dataset.token;
|
||||
|
||||
// Переключатели видимости пароля
|
||||
setupPasswordToggle('toggle-password', 'password', 'eye-icon', 'eye-off-icon');
|
||||
setupPasswordToggle('toggle-confirm-password', 'confirm-password', 'eye-icon-confirm', 'eye-off-icon-confirm');
|
||||
|
||||
// Ограничение ввода пароля
|
||||
passwordInput?.addEventListener('keypress', (e) => {
|
||||
if (passwordInput.value.length >= 12 &&
|
||||
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
passwordInput?.addEventListener('input', (e) => {
|
||||
if (e.target.value.length > 12) {
|
||||
e.target.value = e.target.value.slice(0, 12);
|
||||
}
|
||||
e.target.value = e.target.value.replace(/\s/g, '');
|
||||
|
||||
validatePassword();
|
||||
if (confirmInput.value) validateConfirmPassword();
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
confirmInput?.addEventListener('keypress', (e) => {
|
||||
if (confirmInput.value.length >= 12 &&
|
||||
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
if (e.target.value.length > 12) {
|
||||
e.target.value = e.target.value.slice(0, 12);
|
||||
}
|
||||
e.target.value = e.target.value.replace(/\s/g, '');
|
||||
|
||||
validateConfirmPassword();
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Фокус и blur
|
||||
passwordInput?.addEventListener('focus', () => touchedFields.add('password'));
|
||||
confirmInput?.addEventListener('focus', () => touchedFields.add('confirmPassword'));
|
||||
|
||||
passwordInput?.addEventListener('blur', () => {
|
||||
touchedFields.add('password');
|
||||
if (passwordInput.value.length > 0) validatePassword(true);
|
||||
});
|
||||
|
||||
confirmInput?.addEventListener('blur', () => {
|
||||
touchedFields.add('confirmPassword');
|
||||
if (confirmInput.value.length > 0) validateConfirmPassword(true);
|
||||
});
|
||||
|
||||
// Отправка формы сброса
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (honeypotField?.value) {
|
||||
console.log('Bot detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showAllErrors()) return;
|
||||
|
||||
if (!token) {
|
||||
alert('Ошибка: отсутствует токен сброса');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.textContent = 'Сохранение...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const password = formData.get('password');
|
||||
const passwordConfirm = formData.get('confirmPassword');
|
||||
|
||||
log(`Сброс пароля с токеном: ${token}`);
|
||||
|
||||
// Сохраняем данные в sessionStorage для страницы подтверждения
|
||||
sessionStorage.setItem('passwordResetData', JSON.stringify({
|
||||
password,
|
||||
passwordConfirm
|
||||
}));
|
||||
|
||||
// Редирект на страницу подтверждения с токеном
|
||||
log('Перенаправление на страницу подтверждения');
|
||||
window.location.href = `/auth/password-reset-success?token=${encodeURIComponent(token)}`;
|
||||
|
||||
} catch (error) {
|
||||
logError('❌ Ошибка сброса пароля:', error);
|
||||
// Очищаем sessionStorage при ошибке
|
||||
sessionStorage.removeItem('passwordResetData');
|
||||
alert(error.message || 'Ошибка сброса пароля. Возможно, ссылка устарела.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Начальная проверка
|
||||
checkFormValidity();
|
||||
</script>
|
||||
485
frontend/src/components/auth/login/LoginForm.astro
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
---
|
||||
import Button from "../../base/Button.astro";
|
||||
import Toast from "../../base/Toast.astro";
|
||||
|
||||
// Иконки по умолчанию (SVG строки)
|
||||
const emailIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>`;
|
||||
|
||||
const passwordIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>`;
|
||||
|
||||
// Props для переопределения иконок
|
||||
interface Props {
|
||||
emailIcon?: string;
|
||||
passwordIcon?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
emailIcon: customEmailIcon,
|
||||
passwordIcon: customPasswordIcon,
|
||||
redirectUrl = "/",
|
||||
} = Astro.props;
|
||||
|
||||
// Используем кастомные или дефолтные
|
||||
const finalEmailIcon = customEmailIcon || emailIcon;
|
||||
const finalPasswordIcon = customPasswordIcon || passwordIcon;
|
||||
|
||||
// Константы валидации
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_LENGTH = 12;
|
||||
---
|
||||
|
||||
<form class="space-y-6" id="login-form" novalidate>
|
||||
<!-- Honeypot поле для защиты от ботов (скрытое) -->
|
||||
<div class="honeypot-field" aria-hidden="true">
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
placeholder="Не заполняйте это поле"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalEmailIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxlength="254"
|
||||
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите корректный email адрес
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="password"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Пароль <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalPasswordIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength={MIN_PASSWORD_LENGTH}
|
||||
maxlength={MAX_PASSWORD_LENGTH}
|
||||
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
<!-- Кнопка показа/скрытия пароля -->
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-password"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 cursor-pointer transition-colors"
|
||||
aria-label="Показать пароль"
|
||||
>
|
||||
<svg
|
||||
id="eye-icon"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
id="eye-off-icon"
|
||||
class="w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Пароль должен быть от {MIN_PASSWORD_LENGTH} до {MAX_PASSWORD_LENGTH} символов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<label class="flex items-center gap-2 text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" name="remember" class="w-4 h-4 accent-gold" checked />
|
||||
<span>Запомнить меня</span>
|
||||
</label>
|
||||
<a
|
||||
href="/auth/forgot-password"
|
||||
class="text-gold hover:underline font-medium">Забыли пароль?</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<Button
|
||||
type="submit"
|
||||
text="Войти"
|
||||
variant="primary-white-text"
|
||||
size="md"
|
||||
className="w-full!"
|
||||
id="submit-btn"
|
||||
/>
|
||||
|
||||
<!-- Register link -->
|
||||
<p class="text-center text-sm text-gray-500 m-0">
|
||||
Нет аккаунта? <a
|
||||
href="/auth/register"
|
||||
class="text-gold hover:underline font-medium">Зарегистрироваться</a
|
||||
>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Toast уведомление -->
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
/* Скрываем honeypot поле */
|
||||
.honeypot-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
MIN_PASSWORD_LENGTH,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
redirectUrl,
|
||||
}}
|
||||
>
|
||||
// Элементы формы
|
||||
const form = document.getElementById("login-form");
|
||||
const emailInput = document.getElementById("email");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const submitBtn = document.getElementById("submit-btn");
|
||||
const togglePasswordBtn = document.getElementById("toggle-password");
|
||||
const eyeIcon = document.getElementById("eye-icon");
|
||||
const eyeOffIcon = document.getElementById("eye-off-icon");
|
||||
const honeypotField = document.getElementById("website");
|
||||
|
||||
// Отслеживаем "тронутые" поля
|
||||
const touchedFields = new Set();
|
||||
|
||||
// Валидация email
|
||||
function validateEmail(showError = false) {
|
||||
const value = emailInput.value.trim();
|
||||
const errorEl = emailInput
|
||||
.closest(".group")
|
||||
.querySelector(".error-message");
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (!emailPattern.test(value)) {
|
||||
isValid = false;
|
||||
} else if (value.length > 254) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("email")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
emailInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
emailInput.classList.remove("focus:border-gold", "focus:ring-gold/20");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
emailInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
emailInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация пароля
|
||||
function validatePassword(showError = false) {
|
||||
const value = passwordInput.value;
|
||||
const errorEl = passwordInput
|
||||
.closest(".group")
|
||||
.querySelector(".error-message");
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (
|
||||
value.length < MIN_PASSWORD_LENGTH ||
|
||||
value.length > MAX_PASSWORD_LENGTH
|
||||
) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("password")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
passwordInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
passwordInput.classList.remove(
|
||||
"focus:border-gold",
|
||||
"focus:ring-gold/20",
|
||||
);
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
passwordInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
passwordInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Показать все ошибки
|
||||
function showAllErrors() {
|
||||
touchedFields.add("email");
|
||||
touchedFields.add("password");
|
||||
const isEmailValid = validateEmail(true);
|
||||
const isPasswordValid = validatePassword(true);
|
||||
return isEmailValid && isPasswordValid;
|
||||
}
|
||||
|
||||
// Ограничение ввода для email (только разрешенные символы)
|
||||
emailInput?.addEventListener("keypress", (e) => {
|
||||
if (
|
||||
!/[a-zA-Z0-9@._%+\-]/.test(e.key) &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "@"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Запрет пробелов в email
|
||||
emailInput?.addEventListener("input", (e) => {
|
||||
e.target.value = e.target.value.replace(/\s/g, "");
|
||||
validateEmail();
|
||||
});
|
||||
|
||||
// Ограничение ввода для пароля (жесткий лимит 12 символов)
|
||||
passwordInput?.addEventListener("keypress", (e) => {
|
||||
if (
|
||||
passwordInput.value.length >= MAX_PASSWORD_LENGTH &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
passwordInput?.addEventListener("input", (e) => {
|
||||
if (e.target.value.length > MAX_PASSWORD_LENGTH) {
|
||||
e.target.value = e.target.value.slice(0, MAX_PASSWORD_LENGTH);
|
||||
}
|
||||
e.target.value = e.target.value.replace(/\s/g, "");
|
||||
validatePassword();
|
||||
});
|
||||
|
||||
// Переключение видимости пароля
|
||||
togglePasswordBtn?.addEventListener("click", () => {
|
||||
const type =
|
||||
passwordInput.getAttribute("type") === "password" ? "text" : "password";
|
||||
passwordInput.setAttribute("type", type);
|
||||
|
||||
if (type === "text") {
|
||||
eyeIcon.classList.add("hidden");
|
||||
eyeOffIcon.classList.remove("hidden");
|
||||
togglePasswordBtn.setAttribute("aria-label", "Скрыть пароль");
|
||||
} else {
|
||||
eyeIcon.classList.remove("hidden");
|
||||
eyeOffIcon.classList.add("hidden");
|
||||
togglePasswordBtn.setAttribute("aria-label", "Показать пароль");
|
||||
}
|
||||
});
|
||||
|
||||
// Отслеживание фокуса
|
||||
emailInput?.addEventListener("focus", () => touchedFields.add("email"));
|
||||
passwordInput?.addEventListener("focus", () => touchedFields.add("password"));
|
||||
|
||||
// Валидация при потере фокуса
|
||||
emailInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("email");
|
||||
if (emailInput.value.length > 0) validateEmail(true);
|
||||
});
|
||||
|
||||
passwordInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("password");
|
||||
if (passwordInput.value.length > 0) validatePassword(true);
|
||||
});
|
||||
|
||||
// Отправка формы
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Проверка honeypot (если заполнено — бот)
|
||||
if (honeypotField.value) {
|
||||
console.log("[LOGIN FORM] Bot detected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Если локальная валидация не пройдена — показываем тост с ошибкой
|
||||
if (!showAllErrors()) {
|
||||
console.log("[LOGIN FORM] Валидация не пройдена");
|
||||
if (window.showToast) {
|
||||
window.showToast(
|
||||
"Пожалуйста, проверьте правильность заполнения полей",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[LOGIN FORM] Начало отправки формы");
|
||||
|
||||
// Блокировка кнопки на время отправки
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.textContent = "Вход...";
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
const remember = formData.get("remember") === "on";
|
||||
|
||||
console.log("[LOGIN FORM] Данные формы:", {
|
||||
email,
|
||||
passwordLength: password?.length,
|
||||
remember,
|
||||
});
|
||||
console.log("[LOGIN FORM] Отправка запроса на /api/auth/login");
|
||||
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include", // Для передачи кук
|
||||
body: JSON.stringify({ email, password, remember }),
|
||||
});
|
||||
|
||||
console.log("[LOGIN FORM] Ответ сервера:", response.status);
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[LOGIN FORM] Данные ответа:", data);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[LOGIN FORM] Ошибка входа, обработка...");
|
||||
console.log("[LOGIN FORM] window.showToast существует?", typeof window.showToast);
|
||||
|
||||
// Восстанавливаем кнопку
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
|
||||
// Email не подтверждён — редирект на страницу подтверждения
|
||||
if (response.status === 403 && !data.verified) {
|
||||
window.location.href = `/auth/verify?email=${encodeURIComponent(email)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем сообщение для toaster в зависимости от кода ошибки
|
||||
let toastMessage = data.error || "Неверный email или пароль";
|
||||
|
||||
console.log("[LOGIN FORM] pbStatus:", data.pbStatus);
|
||||
console.log("[LOGIN FORM] errorCode:", data.errorCode);
|
||||
console.log("[LOGIN FORM] Сообщение для toaster:", toastMessage);
|
||||
|
||||
// Дополнительные сообщения для разных кодов
|
||||
if (data.pbStatus === 429) {
|
||||
toastMessage = "Слишком много попыток. Подождите немного";
|
||||
} else if (data.pbStatus === 404) {
|
||||
toastMessage = "Пользователь не найден. Проверьте email";
|
||||
} else if (data.errorCode === "auth_failed") {
|
||||
toastMessage = "Неверный email или пароль";
|
||||
}
|
||||
|
||||
// Показываем тост с ошибкой
|
||||
if (typeof window.showToast === "function") {
|
||||
console.log("[LOGIN FORM] Вызов window.showToast с сообщением:", toastMessage);
|
||||
window.showToast(toastMessage, "error", 4000);
|
||||
} else {
|
||||
console.error("[LOGIN FORM] window.showToast не определён!");
|
||||
// Fallback — alert для отладки
|
||||
alert(toastMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[LOGIN FORM] ✅ Вход успешён, редирект на:", redirectUrl);
|
||||
|
||||
// Редирект после успешного входа (на страницу откуда пришли)
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
console.error("[LOGIN FORM] ❌ Ошибка входа:", error);
|
||||
if (window.showToast) {
|
||||
window.showToast(
|
||||
error.message || "Ошибка входа. Проверьте данные.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
634
frontend/src/components/auth/register/RegisterForm.astro
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
---
|
||||
import Button from "../../base/Button.astro";
|
||||
import Toast from "../../base/Toast.astro";
|
||||
|
||||
// Иконки по умолчанию (SVG строки)
|
||||
const userIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>`;
|
||||
|
||||
const emailIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>`;
|
||||
|
||||
const passwordIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>`;
|
||||
|
||||
const confirmPasswordIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>`;
|
||||
|
||||
// Props для переопределения иконок
|
||||
interface Props {
|
||||
userIcon?: string;
|
||||
emailIcon?: string;
|
||||
passwordIcon?: string;
|
||||
confirmPasswordIcon?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
userIcon: customUserIcon,
|
||||
emailIcon: customEmailIcon,
|
||||
passwordIcon: customPasswordIcon,
|
||||
confirmPasswordIcon: customConfirmPasswordIcon,
|
||||
redirectUrl = "/",
|
||||
} = Astro.props;
|
||||
|
||||
// Используем кастомные или дефолтные
|
||||
const finalUserIcon = customUserIcon || userIcon;
|
||||
const finalEmailIcon = customEmailIcon || emailIcon;
|
||||
const finalPasswordIcon = customPasswordIcon || passwordIcon;
|
||||
const finalConfirmPasswordIcon = customConfirmPasswordIcon || confirmPasswordIcon;
|
||||
|
||||
// Константы валидации
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_LENGTH = 12;
|
||||
---
|
||||
|
||||
<form class="space-y-6" id="register-form" novalidate>
|
||||
<!-- Honeypot поле для защиты от ботов (скрытое) -->
|
||||
<div class="honeypot-field" aria-hidden="true">
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
placeholder="Не заполняйте это поле"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="name"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Имя <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalUserIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Иван Иванов"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Имя должно быть от 2 до 50 символов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalEmailIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxlength="254"
|
||||
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите корректный email адрес
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="password"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Пароль <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalPasswordIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength={MIN_PASSWORD_LENGTH}
|
||||
maxlength={MAX_PASSWORD_LENGTH}
|
||||
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
<!-- Кнопка показа/скрытия пароля -->
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-password"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 cursor-pointer transition-colors"
|
||||
aria-label="Показать пароль"
|
||||
>
|
||||
<svg
|
||||
id="eye-icon"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
id="eye-off-icon"
|
||||
class="w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Пароль должен быть от {MIN_PASSWORD_LENGTH} до {MAX_PASSWORD_LENGTH} символов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Подтверждение пароля <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400"
|
||||
>
|
||||
<Fragment set:html={finalConfirmPasswordIcon} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength={MIN_PASSWORD_LENGTH}
|
||||
maxlength={MAX_PASSWORD_LENGTH}
|
||||
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Пароли не совпадают
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<Button
|
||||
type="submit"
|
||||
text="Зарегистрироваться"
|
||||
variant="primary-white-text"
|
||||
size="md"
|
||||
className="w-full!"
|
||||
id="submit-btn"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<!-- Login link -->
|
||||
<p class="text-center text-sm text-gray-500 m-0">
|
||||
Уже есть аккаунт? <a
|
||||
href="/auth/login"
|
||||
class="text-gold hover:underline font-medium">Войти</a
|
||||
>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Toast уведомление -->
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
/* Скрываем honeypot поле */
|
||||
.honeypot-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
MIN_PASSWORD_LENGTH,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
redirectUrl,
|
||||
}}
|
||||
>
|
||||
// Элементы формы
|
||||
const form = document.getElementById("register-form");
|
||||
const nameInput = document.getElementById("name");
|
||||
const emailInput = document.getElementById("email");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const confirmPasswordInput = document.getElementById("confirm-password");
|
||||
const submitBtn = document.getElementById("submit-btn");
|
||||
const togglePasswordBtn = document.getElementById("toggle-password");
|
||||
const eyeIcon = document.getElementById("eye-icon");
|
||||
const eyeOffIcon = document.getElementById("eye-off-icon");
|
||||
const honeypotField = document.getElementById("website");
|
||||
|
||||
// Отслеживаем "тронутые" поля
|
||||
const touchedFields = new Set();
|
||||
|
||||
// Валидация имени
|
||||
function validateName(showError = false) {
|
||||
const value = nameInput.value.trim();
|
||||
const errorEl = nameInput.closest(".group").querySelector(".error-message");
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (value.length < 2 || value.length > 50) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("name")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
nameInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
nameInput.classList.remove("focus:border-gold", "focus:ring-gold/20");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
nameInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
nameInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация email
|
||||
function validateEmail(showError = false) {
|
||||
const value = emailInput.value.trim();
|
||||
const errorEl = emailInput.closest(".group").querySelector(".error-message");
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (!emailPattern.test(value)) {
|
||||
isValid = false;
|
||||
} else if (value.length > 254) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("email")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
emailInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
emailInput.classList.remove("focus:border-gold", "focus:ring-gold/20");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
emailInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
emailInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация пароля
|
||||
function validatePassword(showError = false) {
|
||||
const value = passwordInput.value;
|
||||
const errorEl = passwordInput.closest(".group").querySelector(".error-message");
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (value.length < MIN_PASSWORD_LENGTH || value.length > MAX_PASSWORD_LENGTH) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("password")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
passwordInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
passwordInput.classList.remove("focus:border-gold", "focus:ring-gold/20");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
passwordInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
passwordInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Валидация подтверждения пароля
|
||||
function validateConfirmPassword(showError = false) {
|
||||
const value = confirmPasswordInput.value;
|
||||
const errorEl = confirmPasswordInput.closest(".group").querySelector(".error-message");
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
} else if (value !== passwordInput.value) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (showError || touchedFields.has("confirm-password")) {
|
||||
if (!isValid && value.length > 0) {
|
||||
errorEl.classList.remove("hidden");
|
||||
confirmPasswordInput.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
confirmPasswordInput.classList.remove("focus:border-gold", "focus:ring-gold/20");
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
confirmPasswordInput.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
confirmPasswordInput.classList.add("focus:border-gold", "focus:ring-gold/20");
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Показать все ошибки
|
||||
function showAllErrors() {
|
||||
touchedFields.add("name");
|
||||
touchedFields.add("email");
|
||||
touchedFields.add("password");
|
||||
touchedFields.add("confirm-password");
|
||||
const isNameValid = validateName(true);
|
||||
const isEmailValid = validateEmail(true);
|
||||
const isPasswordValid = validatePassword(true);
|
||||
const isConfirmPasswordValid = validateConfirmPassword(true);
|
||||
return isNameValid && isEmailValid && isPasswordValid && isConfirmPasswordValid;
|
||||
}
|
||||
|
||||
// Проверка соответствия паролей
|
||||
function passwordsMatch() {
|
||||
return passwordInput.value === confirmPasswordInput.value && passwordInput.value.length > 0;
|
||||
}
|
||||
|
||||
// Обновление состояния кнопки отправки
|
||||
function updateSubmitButton() {
|
||||
const isFormValid = validateName() && validateEmail() && validatePassword() && validateConfirmPassword() && passwordsMatch();
|
||||
submitBtn.disabled = !isFormValid;
|
||||
}
|
||||
|
||||
// Ограничение ввода для email (только разрешенные символы)
|
||||
emailInput?.addEventListener("keypress", (e) => {
|
||||
if (!/[a-zA-Z0-9@._%+\-]/.test(e.key) && !["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "@"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Запрет пробелов в email
|
||||
emailInput?.addEventListener("input", (e) => {
|
||||
e.target.value = e.target.value.replace(/\s/g, "");
|
||||
validateEmail();
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Ограничение ввода для пароля (жесткий лимит 12 символов)
|
||||
passwordInput?.addEventListener("keypress", (e) => {
|
||||
if (passwordInput.value.length >= MAX_PASSWORD_LENGTH && !["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
passwordInput?.addEventListener("input", (e) => {
|
||||
if (e.target.value.length > MAX_PASSWORD_LENGTH) {
|
||||
e.target.value = e.target.value.slice(0, MAX_PASSWORD_LENGTH);
|
||||
}
|
||||
validatePassword();
|
||||
validateConfirmPassword();
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Ограничение ввода для подтверждения пароля
|
||||
confirmPasswordInput?.addEventListener("input", (e) => {
|
||||
if (e.target.value.length > MAX_PASSWORD_LENGTH) {
|
||||
e.target.value = e.target.value.slice(0, MAX_PASSWORD_LENGTH);
|
||||
}
|
||||
validateConfirmPassword();
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Переключение видимости пароля
|
||||
togglePasswordBtn?.addEventListener("click", () => {
|
||||
const type = passwordInput.getAttribute("type") === "password" ? "text" : "password";
|
||||
passwordInput.setAttribute("type", type);
|
||||
|
||||
if (type === "text") {
|
||||
eyeIcon.classList.add("hidden");
|
||||
eyeOffIcon.classList.remove("hidden");
|
||||
togglePasswordBtn.setAttribute("aria-label", "Скрыть пароль");
|
||||
} else {
|
||||
eyeIcon.classList.remove("hidden");
|
||||
eyeOffIcon.classList.add("hidden");
|
||||
togglePasswordBtn.setAttribute("aria-label", "Показать пароль");
|
||||
}
|
||||
});
|
||||
|
||||
// Отслеживание фокуса
|
||||
nameInput?.addEventListener("focus", () => touchedFields.add("name"));
|
||||
emailInput?.addEventListener("focus", () => touchedFields.add("email"));
|
||||
passwordInput?.addEventListener("focus", () => touchedFields.add("password"));
|
||||
confirmPasswordInput?.addEventListener("focus", () => touchedFields.add("confirm-password"));
|
||||
|
||||
// Валидация при потере фокуса
|
||||
nameInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("name");
|
||||
if (nameInput.value.length > 0) validateName(true);
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
emailInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("email");
|
||||
if (emailInput.value.length > 0) validateEmail(true);
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
passwordInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("password");
|
||||
if (passwordInput.value.length > 0) validatePassword(true);
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
confirmPasswordInput?.addEventListener("blur", () => {
|
||||
touchedFields.add("confirm-password");
|
||||
if (confirmPasswordInput.value.length > 0) validateConfirmPassword(true);
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Отправка формы
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Проверка honeypot (если заполнено — бот)
|
||||
if (honeypotField.value) {
|
||||
console.log("[REGISTER FORM] Bot detected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Если локальная валидация не пройдена — показываем тост с ошибкой
|
||||
if (!showAllErrors()) {
|
||||
console.log("[REGISTER FORM] Валидация не пройдена");
|
||||
if (window.showToast) {
|
||||
window.showToast("Пожалуйста, проверьте правильность заполнения полей", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка соответствия паролей
|
||||
if (!passwordsMatch()) {
|
||||
if (window.showToast) {
|
||||
window.showToast("Пароли не совпадают", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[REGISTER FORM] Начало отправки формы");
|
||||
|
||||
// Блокировка кнопки на время отправки
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.textContent = "Регистрация...";
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const name = formData.get("name");
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
const confirmPassword = formData.get("confirm-password");
|
||||
|
||||
console.log("[REGISTER FORM] Данные формы:", {
|
||||
name,
|
||||
email,
|
||||
passwordLength: password?.length,
|
||||
hasConfirmPassword: !!confirmPassword,
|
||||
});
|
||||
console.log("[REGISTER FORM] Отправка запроса на /api/auth/register");
|
||||
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name, email, password, confirmPassword }),
|
||||
});
|
||||
|
||||
console.log("[REGISTER FORM] Ответ сервера:", response.status);
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[REGISTER FORM] Данные ответа:", data);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[REGISTER FORM] Ошибка регистрации, обработка...");
|
||||
|
||||
// Восстанавливаем кнопку
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
|
||||
// Определяем сообщение для toaster
|
||||
let toastMessage = data.error || "Ошибка регистрации";
|
||||
|
||||
if (data.pbStatus === 400) {
|
||||
if (data.code === "validation_unique_email_failed") {
|
||||
toastMessage = "Email уже зарегистрирован";
|
||||
} else if (data.code === "validation_min_length") {
|
||||
toastMessage = "Пароль должен быть не менее 8 символов";
|
||||
} else {
|
||||
toastMessage = data.message || "Ошибка валидации";
|
||||
}
|
||||
} else if (data.pbStatus === 429) {
|
||||
toastMessage = "Слишком много попыток. Подождите немного";
|
||||
}
|
||||
|
||||
// Показываем тост с ошибкой
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(toastMessage, "error", 4000);
|
||||
} else {
|
||||
alert(toastMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[REGISTER FORM] ✅ Регистрация успешна, редирект на:", redirectUrl);
|
||||
|
||||
// Редирект после успешной регистрации
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
console.error("[REGISTER FORM] ❌ Ошибка регистрации:", error);
|
||||
if (window.showToast) {
|
||||
window.showToast(error.message || "Ошибка регистрации. Попробуйте позже.", "error");
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
721
frontend/src/components/base/AuthorizedContactForm.astro
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
---
|
||||
import SectionHeader from "@components/base/SectionHeader.astro";
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
const contactIcons = {
|
||||
phone:
|
||||
"M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z",
|
||||
location:
|
||||
"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
email:
|
||||
"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
|
||||
};
|
||||
|
||||
const PRACTICE_AREAS = [
|
||||
{ value: "civil", label: "Гражданское право" },
|
||||
{ value: "admin", label: "Административное право" },
|
||||
{ value: "family", label: "Семейное право" },
|
||||
{ value: "arbitration", label: "Арбитражные дела" },
|
||||
{ value: "realestate", label: "Недвижимость" },
|
||||
{ value: "svo", label: "СВО" },
|
||||
] as const;
|
||||
|
||||
interface ContactInfo {
|
||||
icon: keyof typeof contactIcons;
|
||||
label: string;
|
||||
type: "phone" | "address" | "email";
|
||||
href?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const contactInfo: ContactInfo[] = [
|
||||
{
|
||||
icon: "phone",
|
||||
label: "Телефон",
|
||||
type: "phone",
|
||||
href: CONTACT_CONSTANTS.phoneHref,
|
||||
value: CONTACT_CONSTANTS.phone,
|
||||
},
|
||||
{
|
||||
icon: "location",
|
||||
label: "Адрес",
|
||||
type: "address",
|
||||
value: CONTACT_CONSTANTS.address,
|
||||
},
|
||||
{
|
||||
icon: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
href: `mailto:${CONTACT_CONSTANTS.email}`,
|
||||
value: CONTACT_CONSTANTS.email,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-24 bg-gray-50" id="contact">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<SectionHeader
|
||||
label="Свяжитесь с нами"
|
||||
title="Получите бесплатную консультацию"
|
||||
description="Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для первичного правового анализа"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<!-- Левая колонка -->
|
||||
<div
|
||||
class="w-full lg:w-2/5 bg-gradient-to-br from-[var(--color-navy)] to-[#1a1f3d] p-10 lg:p-12 text-white relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-48 h-48 bg-[var(--color-blue-primary)] opacity-20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="block text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-4"
|
||||
>Свяжитесь с нами</span
|
||||
>
|
||||
<p class="text-gray-400 leading-relaxed mb-12">
|
||||
Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для
|
||||
первичного правового анализа.
|
||||
</p>
|
||||
|
||||
<div class="space-y-8">
|
||||
{
|
||||
contactInfo.map((item) => (
|
||||
<div class="flex items-start gap-4 group">
|
||||
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center flex-shrink-0 group-hover:bg-[var(--color-gold)] transition-colors">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d={contactIcons[item.icon]}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.href && item.type !== "phone" ? (
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : item.type === "phone" ? (
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors cursor-pointer text-left"
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
) : (
|
||||
<span class="text-lg font-bold">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка -->
|
||||
<div class="w-full lg:w-3/5 p-10 lg:p-12 bg-white">
|
||||
<!-- Блок для неавторизованных -->
|
||||
<div id="auth-required-block" class="h-full flex flex-col items-center justify-center text-center">
|
||||
<!-- Иконка замка -->
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full flex items-center justify-center mb-6 shadow-lg shadow-[var(--color-gold)]/30">
|
||||
<svg
|
||||
class="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-3">
|
||||
Требуется авторизация
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-gray-500 text-base mb-8 max-w-md">
|
||||
Для отправки запроса на консультацию необходимо войти в систему
|
||||
или зарегистрироваться. Это обеспечит безопасность ваших
|
||||
персональных данных.
|
||||
</p>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<a
|
||||
href="/auth/login"
|
||||
class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white font-bold rounded-xl shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Войти
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/auth/register"
|
||||
class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-white border-2 border-[var(--color-gold)] text-[var(--color-gold)] font-bold rounded-xl hover:bg-[var(--color-gold)] hover:text-white transition-all duration-300"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
Зарегистрироваться
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Дополнительная информация -->
|
||||
<div class="mt-8 pt-8 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400">
|
||||
Уже есть аккаунт?{" "}
|
||||
<a
|
||||
href="/auth/login"
|
||||
class="text-[var(--color-gold)] font-medium hover:underline"
|
||||
>
|
||||
Войти сейчас
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма для авторизованных (скрыта по умолчанию) -->
|
||||
<form class="space-y-8 hidden" id="consultation-form" novalidate>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Имя -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="name"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Ваше имя <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
pattern="[А-Яа-яЁёA-Za-z\s\-]+"
|
||||
placeholder="Иван Иванов"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите корректное имя (минимум 2 символа)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Телефон -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="phone"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Телефон <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
pattern="\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите полный номер телефона
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сфера вопроса -->
|
||||
<div class="relative">
|
||||
<label
|
||||
for="practice"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Сфера вопроса
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
id="practice"
|
||||
name="practice"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 appearance-none cursor-pointer transition-all"
|
||||
>
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
<option value={area.value}>{area.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщение -->
|
||||
<div class="relative">
|
||||
<label
|
||||
for="message"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Ваше сообщение <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
placeholder="Опишите ситуацию..."
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 resize-none transition-all"
|
||||
></textarea>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="error-message hidden text-red-500 text-xs">
|
||||
Опишите ситуацию подробнее (минимум 10 символов)
|
||||
</span>
|
||||
<span class="char-count text-xs text-gray-400 ml-auto">
|
||||
0 / 1000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
disabled
|
||||
class="w-full py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white font-bold rounded-xl shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 hover:-translate-y-0.5 hover:cursor-pointer transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none"
|
||||
>
|
||||
<span class="btn-text">Отправить запрос</span>
|
||||
<span class="btn-loading hidden">Отправка...</span>
|
||||
</button>
|
||||
|
||||
<p class="text-center text-xs text-gray-400">
|
||||
Нажимая кнопку, вы соглашаетесь с{" "}
|
||||
<a
|
||||
href="/policy"
|
||||
class="text-[var(--color-gold)] hover:underline"
|
||||
>
|
||||
политикой конфиденциальности
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { pb, loadAuthFromCookie } from '@lib/auth.ts';
|
||||
|
||||
// Элементы
|
||||
const authRequiredBlock = document.getElementById('auth-required-block');
|
||||
const consultationForm = document.getElementById('consultation-form') as HTMLFormElement;
|
||||
|
||||
// Функция переключения между блоками
|
||||
function updateAuthState(isAuthed: boolean) {
|
||||
if (isAuthed) {
|
||||
// Пользователь авторизован - показываем форму
|
||||
authRequiredBlock?.classList.add('hidden');
|
||||
consultationForm?.classList.remove('hidden');
|
||||
initFormValidation();
|
||||
} else {
|
||||
// Пользователь не авторизован - показываем блок авторизации
|
||||
authRequiredBlock?.classList.remove('hidden');
|
||||
consultationForm?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка авторизации через API (как в Header)
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
updateAuthState(data.authenticated);
|
||||
} catch (error) {
|
||||
console.error('[AuthorizedContactForm] Ошибка проверки авторизации:', error);
|
||||
updateAuthState(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Принудительно загружаем сессию из cookies (для pb)
|
||||
loadAuthFromCookie();
|
||||
|
||||
// Проверяем авторизацию через API
|
||||
checkAuth();
|
||||
|
||||
// Подписываемся на изменения аутентификации (для случаев, когда вход/выход происходит на той же странице)
|
||||
pb.authStore.onChange(() => {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
// Инициализация валидации формы
|
||||
function initFormValidation() {
|
||||
// Типы для валидации
|
||||
type ValidationRule = {
|
||||
pattern?: RegExp;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const validationRules: Record<string, ValidationRule> = {
|
||||
name: {
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
pattern: /^[А-Яа-яЁёA-Za-z\s\-]+$/,
|
||||
},
|
||||
phone: {
|
||||
required: true,
|
||||
pattern: /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/,
|
||||
},
|
||||
message: {
|
||||
required: true,
|
||||
minLength: 10,
|
||||
maxLength: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
// Элементы формы
|
||||
const form = consultationForm;
|
||||
const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement;
|
||||
const btnText = submitBtn.querySelector(".btn-text") as HTMLSpanElement;
|
||||
const btnLoading = submitBtn.querySelector(".btn-loading") as HTMLSpanElement;
|
||||
|
||||
// Отслеживаем, было ли поле в фокусе (для показа ошибок только после взаимодействия)
|
||||
const touchedFields = new Set<string>();
|
||||
|
||||
// Валидация поля
|
||||
function validateField(
|
||||
field: HTMLInputElement | HTMLTextAreaElement,
|
||||
showError: boolean = false,
|
||||
): boolean {
|
||||
const name = field.name;
|
||||
const rules = validationRules[name];
|
||||
const errorEl = field.parentElement?.querySelector(
|
||||
".error-message",
|
||||
) as HTMLElement;
|
||||
|
||||
if (!rules) return true;
|
||||
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
|
||||
// Проверка обязательности
|
||||
if (rules.required && !field.value.trim()) {
|
||||
isValid = false;
|
||||
errorMsg = "Обязательное поле";
|
||||
}
|
||||
// Проверка минимальной длины (только если поле не пустое)
|
||||
else if (
|
||||
rules.minLength &&
|
||||
field.value.length > 0 &&
|
||||
field.value.length < rules.minLength
|
||||
) {
|
||||
isValid = false;
|
||||
errorMsg = `Минимум ${rules.minLength} символов`;
|
||||
}
|
||||
// Проверка максимальной длины
|
||||
else if (rules.maxLength && field.value.length > rules.maxLength) {
|
||||
isValid = false;
|
||||
errorMsg = `Максимум ${rules.maxLength} символов`;
|
||||
}
|
||||
// Проверка паттерна (только если поле не пустое)
|
||||
else if (
|
||||
rules.pattern &&
|
||||
field.value.length > 0 &&
|
||||
!rules.pattern.test(field.value)
|
||||
) {
|
||||
isValid = false;
|
||||
errorMsg = "Некорректный формат";
|
||||
}
|
||||
|
||||
// Отображение ошибки только если поле было в фокусе или принудительный показ
|
||||
if (errorEl && (showError || touchedFields.has(name))) {
|
||||
if (!isValid && (field.value.length > 0 || showError)) {
|
||||
errorEl.textContent = errorMsg;
|
||||
errorEl.classList.remove("hidden");
|
||||
field.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
field.classList.remove(
|
||||
"focus:border-[var(--color-gold)]",
|
||||
"focus:ring-[var(--color-gold)]/20",
|
||||
);
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
field.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
field.classList.add(
|
||||
"focus:border-[var(--color-gold)]",
|
||||
"focus:ring-[var(--color-gold)]/20",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Проверка всей формы (без показа ошибок, только для активации кнопки)
|
||||
function checkFormValidity(): boolean {
|
||||
const fields = form.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>("input[required], textarea[required]");
|
||||
let isValid = true;
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!validateField(field, false)) isValid = false;
|
||||
});
|
||||
|
||||
submitBtn.disabled = !isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Показать все ошибки (при попытке отправки)
|
||||
function showAllErrors(): boolean {
|
||||
const fields = form.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>("input[required], textarea[required]");
|
||||
let isValid = true;
|
||||
|
||||
fields.forEach((field) => {
|
||||
touchedFields.add(field.name);
|
||||
if (!validateField(field, true)) isValid = false;
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Маска телефона с ограничением ввода
|
||||
const phoneInput = document.getElementById("phone") as HTMLInputElement;
|
||||
|
||||
phoneInput?.addEventListener("keypress", (e) => {
|
||||
// Разрешаем только цифры и управляющие клавиши
|
||||
if (
|
||||
!/\d/.test(e.key) &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
phoneInput?.addEventListener("input", (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let value = target.value.replace(/\D/g, "");
|
||||
|
||||
// Ограничиваем длину
|
||||
if (value.length > 11) value = value.slice(0, 11);
|
||||
|
||||
// Убираем 7 или 8 в начале
|
||||
if (value.startsWith("7")) value = value.slice(1);
|
||||
if (value.startsWith("8")) value = value.slice(1);
|
||||
|
||||
// Форматируем
|
||||
let formatted = "+7";
|
||||
if (value.length > 0) formatted += " (" + value.slice(0, 3);
|
||||
if (value.length > 3) formatted += ") " + value.slice(3, 6);
|
||||
if (value.length > 6) formatted += "-" + value.slice(6, 8);
|
||||
if (value.length > 8) formatted += "-" + value.slice(8, 10);
|
||||
|
||||
target.value = formatted;
|
||||
validateField(target);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Ограничение ввода для имени (только буквы, пробелы, дефис)
|
||||
const nameInput = document.getElementById("name") as HTMLInputElement;
|
||||
|
||||
nameInput?.addEventListener("keypress", (e) => {
|
||||
if (
|
||||
!/[А-Яа-яЁёA-Za-z\s\-]/.test(e.key) &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
nameInput?.addEventListener("input", () => {
|
||||
validateField(nameInput);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Счетчик символов для сообщения
|
||||
const messageInput = document.getElementById(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
const charCount = messageInput?.parentElement?.querySelector(
|
||||
".char-count",
|
||||
) as HTMLElement;
|
||||
|
||||
messageInput?.addEventListener("input", () => {
|
||||
const length = messageInput.value.length;
|
||||
if (charCount) {
|
||||
charCount.textContent = `${length} / 1000`;
|
||||
charCount.classList.toggle("text-red-500", length > 1000);
|
||||
}
|
||||
validateField(messageInput);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Отмечаем поле как "тронутое" при фокусе
|
||||
form.querySelectorAll("input, textarea").forEach((field) => {
|
||||
field.addEventListener("focus", () => {
|
||||
touchedFields.add((field as HTMLInputElement).name);
|
||||
});
|
||||
});
|
||||
|
||||
// Валидация при потере фокуса (показываем ошибки только если поле было заполнено неверно)
|
||||
form.querySelectorAll("input, textarea").forEach((field) => {
|
||||
field.addEventListener("blur", () => {
|
||||
const input = field as HTMLInputElement;
|
||||
touchedFields.add(input.name);
|
||||
// Показываем ошибку только если поле не пустое и невалидно, или если пытались отправить
|
||||
if (input.value.length > 0) {
|
||||
validateField(input, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Отправка формы
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Показываем все ошибки при попытке отправки
|
||||
if (!showAllErrors()) return;
|
||||
|
||||
// Блокировка кнопки
|
||||
submitBtn.disabled = true;
|
||||
btnText.classList.add("hidden");
|
||||
btnLoading.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// Отправка на API
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "Ошибка при отправке");
|
||||
}
|
||||
|
||||
// Успех
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(
|
||||
"Заявка успешно отправлена! Мы свяжемся с вами в ближайшее время.",
|
||||
"success",
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
touchedFields.clear();
|
||||
checkFormValidity(); // Сброс состояния кнопки
|
||||
|
||||
// Сброс счётчика символов
|
||||
if (charCount) {
|
||||
charCount.textContent = "0 / 1000";
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ContactForm] Ошибка:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Ошибка при отправке. Попробуйте позже.";
|
||||
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(errorMessage, "error", 5000);
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
btnText.classList.remove("hidden");
|
||||
btnLoading.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Начальная проверка (без показа ошибок)
|
||||
checkFormValidity();
|
||||
}
|
||||
</script>
|
||||
175
frontend/src/components/base/Breadcrumbs.astro
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
const { pathname = "" } = Astro.url;
|
||||
export interface Props {
|
||||
blogPostTitle?: string;
|
||||
}
|
||||
|
||||
const { blogPostTitle } = Astro.props;
|
||||
|
||||
// Константа для маппинга путей в названия
|
||||
const BREADCRUMB_NAMES: Record<string, string> = {
|
||||
"": "Главная",
|
||||
services: "Услуги",
|
||||
criminal: "Уголовные дела",
|
||||
civil: "Гражданские дела",
|
||||
family: "Семейные дела",
|
||||
arbitration: "Арбитражные дела",
|
||||
tax: "Налоговые дела",
|
||||
"real-estate": "Недвижимость",
|
||||
cases: "Кейсы",
|
||||
blog: "Блог",
|
||||
archive: "Архив",
|
||||
search: "Поиск",
|
||||
category: "Категория",
|
||||
faq: "FAQ",
|
||||
about: "О бюро",
|
||||
contacts: "Контакты",
|
||||
reviews: "Отзывы",
|
||||
administrative: "Административные дела",
|
||||
svo: "СВО дела",
|
||||
"debt-protection": "Защита должников",
|
||||
auth: "Авторизация",
|
||||
login: "Вход",
|
||||
register: "Регистрация",
|
||||
"forgot-password": "Восстановление пароля",
|
||||
"reset-password": "Сброс пароля",
|
||||
admin: "Админ-панель",
|
||||
"privacy-policy": "Политика конфиденциальности",
|
||||
"legal-info": "Правовая информация",
|
||||
prices: "Цены",
|
||||
};
|
||||
|
||||
// Функция для форматирования пути в читаемое название
|
||||
const formatBreadcrumbName = (pathSegment: string, isBlogPost: boolean = false, blogTitle?: string): string => {
|
||||
if (isBlogPost && blogTitle) {
|
||||
// Если это статья блога и у нас есть заголовок, используем его
|
||||
return blogTitle;
|
||||
}
|
||||
|
||||
if (isBlogPost) {
|
||||
// Для статей блога возвращаем транслитерированное имя без форматирования
|
||||
// или можно реализовать логику получения заголовка статьи
|
||||
return pathSegment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
return (
|
||||
BREADCRUMB_NAMES[pathSegment] ||
|
||||
pathSegment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
);
|
||||
};
|
||||
|
||||
// Генерируем массив хлебных крошек
|
||||
const pathSegments = pathname.split("/").filter((segment) => segment !== "");
|
||||
const breadcrumbs = [];
|
||||
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
const path = "/" + pathSegments.slice(0, i + 1).join("/");
|
||||
|
||||
// Проверяем, является ли это статьей блога (если это последний сегмент и предыдущий сегмент - "blog")
|
||||
const isBlogPost = (i === pathSegments.length - 1) && (i > 0) && (pathSegments[i - 1] === "blog");
|
||||
|
||||
// Для статьи блога используем заголовок статьи, если он предоставлен
|
||||
const breadcrumbName = isBlogPost && blogPostTitle ? blogPostTitle : formatBreadcrumbName(segment, false);
|
||||
|
||||
breadcrumbs.push({
|
||||
name: breadcrumbName,
|
||||
path: path,
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляем главную страницу в начало
|
||||
if (breadcrumbs.length > 0) {
|
||||
breadcrumbs.unshift({ name: "Главная", path: "/" });
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
breadcrumbs.length > 1 && (
|
||||
<>
|
||||
{/* Мобильная версия - только последняя крошка */}
|
||||
<div class="block md:hidden w-full bg-white py-2">
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16">
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
class="text-[var(--color-blue-primary)] hover:text-[var(--color-blue-primary)]/80 transition-colors duration-300"
|
||||
>
|
||||
Главная
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg
|
||||
class="w-4 h-4 mx-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-700 truncate max-w-[200px]">
|
||||
{breadcrumbs[breadcrumbs.length - 1]?.name}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Десктопная версия */}
|
||||
<div class="hidden md:block w-full bg-white py-2">
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16">
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<li class="flex items-center" data-path={crumb.path}>
|
||||
{index > 0 && (
|
||||
<svg
|
||||
class="w-4 h-4 mx-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<span
|
||||
aria-current="page"
|
||||
class="font-medium text-gray-700 truncate max-w-[150px]"
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={crumb.path}
|
||||
class="text-[var(--color-blue-primary)] hover:text-[var(--color-blue-primary)]/80 transition-colors duration-300 font-medium truncate max-w-[150px]"
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/base/Button.astro
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
interface Props {
|
||||
text: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
variant?: 'primary' | 'primary-white-text' | 'outline' | 'outline-white' | 'gold-ghost' | 'telegram' | 'whatsapp';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
id?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
text,
|
||||
href,
|
||||
className = "",
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
id,
|
||||
type = 'button',
|
||||
disabled = false
|
||||
} = Astro.props;
|
||||
|
||||
// Стили вариантов (адаптированы под дизайн Адвоката)
|
||||
const variants = {
|
||||
// Золотая кнопка (основная)
|
||||
primary: "bg-[#bf9b58] text-[#151b26] border border-[#bf9b58] hover:bg-[#d4b068] hover:border-[#d4b068]",
|
||||
|
||||
// Золотая кнопка с белым текстом (для темных фонов)
|
||||
'primary-white-text': "bg-[#bf9b58] text-white border border-[#bf9b58] hover:bg-[#d4b068] hover:border-[#d4b068]",
|
||||
|
||||
// Золотая обводка (для светлых или темных фонов)
|
||||
outline: "bg-transparent border border-[#bf9b58] text-[#bf9b58] hover:bg-[#bf9b58] hover:text-[#151b26]",
|
||||
|
||||
// Белая/Серая обводка (Специально для Hero - "Направления практики")
|
||||
'outline-white': "bg-transparent border border-gray-500 text-white hover:bg-white/10 hover:border-white",
|
||||
|
||||
// Текстовая кнопка золотого цвета
|
||||
'gold-ghost': "bg-transparent text-[#bf9b58] hover:text-[#d4b068]",
|
||||
|
||||
// Кнопка Telegram (официальный цвет #0088cc)
|
||||
'telegram': "bg-[#0088cc] text-white hover:bg-[#006699]",
|
||||
|
||||
// Кнопка WhatsApp (официальный цвет #25D366)
|
||||
'whatsapp': "bg-[#25D366] text-white hover:bg-[#128C7E]"
|
||||
};
|
||||
|
||||
// Стили размеров
|
||||
const sizes = {
|
||||
sm: "px-4 py-2 text-xs rounded-xl uppercase tracking-wider", // Убрал скругления для строгости
|
||||
md: "px-8 py-4 text-sm rounded-xl uppercase tracking-wider w-full sm:w-auto", // Размер как в Hero
|
||||
lg: "px-10 py-5 text-base rounded-xl uppercase tracking-wider"
|
||||
};
|
||||
|
||||
const baseStyles = "inline-flex items-center justify-center font-bold transition-all duration-300 active:scale-95 text-center cursor-pointer";
|
||||
|
||||
const Element = href ? 'a' : 'button';
|
||||
---
|
||||
|
||||
<Element
|
||||
{href}
|
||||
{id}
|
||||
type={!href ? type : undefined}
|
||||
disabled={!href ? disabled : undefined}
|
||||
class:list={[baseStyles, variants[variant], sizes[size], className]}
|
||||
>
|
||||
{text}
|
||||
</Element>
|
||||
235
frontend/src/components/base/CardServiceGrid.astro
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
---
|
||||
export interface Service {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
price: string;
|
||||
duration?: string;
|
||||
cases?: number;
|
||||
color?: string;
|
||||
popular?: boolean;
|
||||
result?: string;
|
||||
urgent?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
services: Service[];
|
||||
sectionId?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
bgColor?: 'navy' | 'navy-dark' | 'gray' | 'white';
|
||||
accentColor?: 'gold' | 'blue' | 'green' | 'red';
|
||||
showResults?: boolean;
|
||||
showUrgentBadge?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
services,
|
||||
sectionId = 'services',
|
||||
title = 'Наши услуги',
|
||||
subtitle,
|
||||
description,
|
||||
bgColor = 'navy',
|
||||
accentColor = 'gold',
|
||||
showResults = false,
|
||||
showUrgentBadge = false
|
||||
} = Astro.props;
|
||||
|
||||
// Определение цветовых схем в зависимости от фона
|
||||
const bgClasses = {
|
||||
navy: 'bg-[var(--color-navy)]',
|
||||
'navy-dark': 'bg-[var(--color-navy-dark)]',
|
||||
gray: 'bg-gray-50',
|
||||
white: 'bg-white'
|
||||
};
|
||||
|
||||
const cardBgClasses = {
|
||||
navy: 'bg-[var(--color-navy-dark)]',
|
||||
'navy-dark': 'bg-[var(--color-navy)]',
|
||||
gray: 'bg-white',
|
||||
white: 'bg-gray-50'
|
||||
};
|
||||
|
||||
const textClasses = {
|
||||
navy: 'text-[var(--color-white)]',
|
||||
'navy-dark': 'text-[var(--color-white)]',
|
||||
gray: 'text-gray-900',
|
||||
white: 'text-gray-900'
|
||||
};
|
||||
|
||||
const textColorClasses = {
|
||||
navy: 'text-[var(--color-gray-500)]',
|
||||
'navy-dark': 'text-[var(--color-gray-500)]',
|
||||
gray: 'text-gray-600',
|
||||
white: 'text-gray-600'
|
||||
};
|
||||
|
||||
const accentClasses = {
|
||||
gold: '[var(--color-gold)]',
|
||||
blue: '[var(--color-blue-primary)]',
|
||||
green: '[var(--color-green-primary)]',
|
||||
red: '[var(--color-red-primary)]'
|
||||
};
|
||||
|
||||
const borderColorClasses = {
|
||||
navy: '[var(--color-gray-600)]/10',
|
||||
'navy-dark': '[var(--color-gray-600)]/10',
|
||||
gray: 'gray-200',
|
||||
white: 'gray-200'
|
||||
};
|
||||
|
||||
const gradientClasses = {
|
||||
gold: 'from-[var(--color-gold)]/20 to-[var(--color-gold-hover)]/20',
|
||||
blue: 'from-[var(--color-blue-primary)]/20 to-[var(--color-blue-secondary)]/20',
|
||||
green: 'from-[var(--color-green-primary)]/20 to-[var(--color-green-secondary)]/20',
|
||||
red: 'from-[var(--color-red-primary)]/20 to-[var(--color-red-secondary)]/20'
|
||||
};
|
||||
|
||||
const urgentBorderColor = 'border-red-500/30 hover:border-red-500/60';
|
||||
const urgentShadow = 'hover:shadow-red-500/10';
|
||||
const normalBorderColor = `[var(--color-gray-600)]/20 hover:border-${accentClasses[accentColor]}/50`;
|
||||
const normalShadow = `hover:shadow-${accentClasses[accentColor]}/10`;
|
||||
|
||||
const urgentIconBg = 'bg-red-500/10';
|
||||
const urgentIconText = 'text-red-400';
|
||||
const normalIconBg = `${accentClasses[accentColor]}/10`;
|
||||
const normalIconText = accentClasses[accentColor];
|
||||
|
||||
const resultBg = (isUrgent: boolean) => isUrgent ? 'bg-red-500/10 border border-red-500/20' : 'bg-green-500/10 border border-green-500/20';
|
||||
const resultText = (isUrgent: boolean) => isUrgent ? 'text-red-400' : 'text-green-400';
|
||||
const resultIcon = (isUrgent: boolean) => isUrgent ? 'text-red-400' : 'text-green-400';
|
||||
---
|
||||
|
||||
<section id={sectionId} class={`py-16 md:py-24 ${bgClasses[bgColor]} relative overflow-hidden`}>
|
||||
<!-- Фоновые элементы -->
|
||||
{bgColor === 'navy' && (
|
||||
<>
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
<div class="absolute top-1/4 right-0 w-96 h-96 bg-[var(--color-gold)] opacity-[0.02] rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-1/4 left-0 w-96 h-96 bg-[var(--color-gold)] opacity-[0.02] rounded-full blur-3xl"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bgColor === 'navy-dark' && (
|
||||
<>
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--color-gold)] to-transparent opacity-30"></div>
|
||||
<div class="absolute top-20 right-0 w-72 h-72 bg-[var(--color-gold)] opacity-[0.02] rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-20 left-0 w-96 h-96 bg-[var(--color-blue-primary)] opacity-[0.02] rounded-full blur-3xl"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bgColor === 'gray' && (
|
||||
<>
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-[var(--color-blue-primary)] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16">
|
||||
{/* Заголовок секции */}
|
||||
<div class="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
{subtitle && (
|
||||
<span class={`inline-block px-4 py-2 bg-${accentClasses[accentColor]}/10 border border-${accentClasses[accentColor]}/20 rounded-full text-${accentClasses[accentColor]} text-xs font-bold uppercase tracking-wider mb-4 md:mb-6`}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-white)] mb-4 md:mb-6">
|
||||
<span set:html={title} />
|
||||
</h2>
|
||||
{description && (
|
||||
<p class={`text-${accentClasses[accentColor]}/60 text-base md:text-lg`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Сетка карточек */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((service, index) => (
|
||||
<div class={`group relative border ${service.urgent ? urgentBorderColor : normalBorderColor} rounded-2xl overflow-hidden transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl ${service.urgent ? urgentShadow : normalShadow} ${service.popular ? 'ring-1 ring-[var(--color-gold)] ring-offset-2 ring-offset-[var(--color-navy-dark)]' : ''}`}>
|
||||
|
||||
{/* Метка срочности */}
|
||||
{service.urgent && showUrgentBadge && (
|
||||
<div class="absolute -top-3 left-6 px-3 py-1 bg-red-500 text-white text-xs font-bold uppercase rounded-full animate-pulse">
|
||||
Требует срочности
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Популярный бейдж */}
|
||||
{service.popular && (
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 bg-[var(--color-gold)] text-[var(--color-white)] text-xs font-bold uppercase tracking-wider rounded-full shadow-lg">
|
||||
Популярное
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Градиентный оверлей при hover */}
|
||||
{service.color && !service.urgent && (
|
||||
<div class={`absolute inset-0 bg-gradient-to-br ${gradientClasses[accentColor]} opacity-0 group-hover:opacity-100 transition-opacity duration-500`}></div>
|
||||
)}
|
||||
|
||||
<div class="relative p-6">
|
||||
{/* Иконка или эмодзи */}
|
||||
<div class="flex justify-center md:justify-start mb-4">
|
||||
{service.emoji ? (
|
||||
<div class={`w-12 h-12 rounded-xl ${service.urgent ? urgentIconBg : normalIconBg} flex items-center justify-center mb-4 text-2xl group-hover:scale-110 group-hover:bg-[var(--color-gold)]/10 group-hover:border-[var(--color-gold)]/30 transition-all duration-300`}>
|
||||
{service.emoji}
|
||||
</div>
|
||||
) : (
|
||||
<div class={`w-12 h-12 rounded-xl ${service.urgent ? urgentIconBg : normalIconBg} flex items-center justify-center group-hover:bg-[var(--color-gold)] group-hover:border-[var(--color-gold)] transition-all duration-300`}>
|
||||
<svg class={`w-6 h-6 ${service.urgent ? urgentIconText : normalIconText}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={service.icon}></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<h3 class="text-xl font-bold text-[var(--color-white)] mb-2 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-gray-500)] text-sm mb-4 line-clamp-2 group-hover:text-[var(--color-gray-400)] transition-colors">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
{/* Результат */}
|
||||
{showResults && service.result && (
|
||||
<div class={`p-3 rounded-lg ${resultBg(!!service.urgent)} mb-4`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class={`w-4 h-4 ${resultIcon(!!service.urgent)}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class={`text-sm font-bold ${resultText(!!service.urgent)}`}>{service.result}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Цена и кнопка */}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-[var(--color-gray-600)]/10">
|
||||
<div>
|
||||
<span class="text-xs text-[var(--color-gray-500)] block mb-1">Стоимость</span>
|
||||
<span class="text-lg font-black text-[var(--color-gold)]">{service.price}</span>
|
||||
</div>
|
||||
<button class={`px-4 py-2 rounded-lg font-bold text-sm transition-all ${service.urgent ? 'bg-red-500 hover:bg-red-600 text-white' : 'bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-navy)]'}`}>
|
||||
{service.urgent ? 'Получить защиту' : 'Подробнее'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Раскрывающаяся часть */}
|
||||
{!service.urgent && (
|
||||
<div class="max-h-0 group-hover:max-h-24 transition-all duration-500 overflow-hidden bg-[var(--color-navy)]/50">
|
||||
<div class="p-6 pt-0">
|
||||
<button data-consultation-modal class="w-full py-3 bg-[var(--color-gold)]/10 hover:bg-[var(--color-gold)]/20 border border-[var(--color-gold)]/20 rounded-lg text-[var(--color-gold)] text-sm font-medium transition-colors cursor-pointer">
|
||||
Получить консультацию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
141
frontend/src/components/base/ConsultationModal.astro
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from '@constants/constants.ts';
|
||||
---
|
||||
|
||||
<!-- Модальное окно с номером -->
|
||||
<div
|
||||
id="phone-modal"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] hidden items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-2xl p-8 max-w-md w-full relative shadow-2xl animate-fade-in">
|
||||
<!-- Кнопка закрытия -->
|
||||
<button
|
||||
id="close-modal"
|
||||
class="absolute top-4 right-4 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors cursor-pointer"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="w-16 h-16 bg-[#bf9b58] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-xl font-bold text-center text-gray-900 mb-2">
|
||||
Позвоните нам
|
||||
</h3>
|
||||
|
||||
<!-- Номер телефона -->
|
||||
<a
|
||||
href={CONTACT_CONSTANTS.phoneHref}
|
||||
class="block text-3xl font-bold text-[#bf9b58] text-center mb-4 hover:underline"
|
||||
>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</a>
|
||||
|
||||
<p class="text-gray-500 text-center text-sm mb-6">
|
||||
Нажмите на номер, чтобы позвонить, или скопируйте его
|
||||
</p>
|
||||
|
||||
<!-- Кнопка копирования -->
|
||||
<button
|
||||
id="copy-phone-btn"
|
||||
class="w-full py-3 border-2 border-[#bf9b58] text-[#bf9b58] font-bold rounded-xl hover:bg-[#bf9b58] hover:text-white transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 01-2-2V5a2 2 0 012-2h4"></path>
|
||||
</svg>
|
||||
<span id="copy-text">Скопировать номер</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const PHONE_NUMBER = "+79222538375";
|
||||
|
||||
const consultationBtns = document.querySelectorAll<HTMLElement>("[data-consultation-modal]");
|
||||
const phoneModal = document.getElementById("phone-modal");
|
||||
const closeModal = document.getElementById("close-modal");
|
||||
const copyPhoneBtn = document.getElementById("copy-phone-btn");
|
||||
const copyText = document.getElementById("copy-text");
|
||||
|
||||
// Открытие модального окна для всех кнопок
|
||||
consultationBtns.forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (phoneModal) {
|
||||
phoneModal.classList.remove("hidden");
|
||||
phoneModal.classList.add("flex");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Закрытие модального окна
|
||||
closeModal?.addEventListener("click", () => {
|
||||
if (phoneModal) {
|
||||
phoneModal.classList.add("hidden");
|
||||
phoneModal.classList.remove("flex");
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по клику вне окна
|
||||
phoneModal?.addEventListener("click", (e) => {
|
||||
if (e.target === phoneModal) {
|
||||
phoneModal.classList.add("hidden");
|
||||
phoneModal.classList.remove("flex");
|
||||
}
|
||||
});
|
||||
|
||||
// Копирование номера
|
||||
copyPhoneBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(PHONE_NUMBER);
|
||||
if (copyText) {
|
||||
copyText.textContent = "Скопировано!";
|
||||
}
|
||||
copyPhoneBtn?.classList.add("bg-[#bf9b58]", "text-white");
|
||||
copyPhoneBtn?.classList.remove("text-[#bf9b58]");
|
||||
|
||||
setTimeout(() => {
|
||||
if (copyText) {
|
||||
copyText.textContent = "Скопировать номер";
|
||||
}
|
||||
copyPhoneBtn?.classList.remove("bg-[#bf9b58]", "text-white");
|
||||
copyPhoneBtn?.classList.add("text-[#bf9b58]");
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Не удалось скопировать номер:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по Escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && phoneModal && !phoneModal.classList.contains("hidden")) {
|
||||
phoneModal.classList.add("hidden");
|
||||
phoneModal.classList.remove("flex");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
582
frontend/src/components/base/ContactForm.astro
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
---
|
||||
import SectionHeader from "@components/base/SectionHeader.astro";
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
const contactIcons = {
|
||||
phone:
|
||||
"M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z",
|
||||
location:
|
||||
"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
email:
|
||||
"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
|
||||
};
|
||||
|
||||
const PRACTICE_AREAS = [
|
||||
{ value: "civil", label: "Гражданское право" },
|
||||
{ value: "admin", label: "Административное право" },
|
||||
{ value: "family", label: "Семейное право" },
|
||||
{ value: "arbitration", label: "Арбитражные дела" },
|
||||
{ value: "realestate", label: "Недвижимость" },
|
||||
{ value: "svo", label: "СВО" },
|
||||
] as const;
|
||||
|
||||
interface ContactInfo {
|
||||
icon: keyof typeof contactIcons;
|
||||
label: string;
|
||||
type: "phone" | "address" | "email";
|
||||
href?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const contactInfo: ContactInfo[] = [
|
||||
{
|
||||
icon: "phone",
|
||||
label: "Телефон",
|
||||
type: "phone",
|
||||
href: CONTACT_CONSTANTS.phoneHref,
|
||||
value: CONTACT_CONSTANTS.phone,
|
||||
},
|
||||
{
|
||||
icon: "location",
|
||||
label: "Адрес",
|
||||
type: "address",
|
||||
value: CONTACT_CONSTANTS.address,
|
||||
},
|
||||
{
|
||||
icon: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
href: `mailto:${CONTACT_CONSTANTS.email}`,
|
||||
value: CONTACT_CONSTANTS.email,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-24 bg-gray-50" id="contact">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<SectionHeader
|
||||
label="Свяжитесь с нами"
|
||||
title="Получите бесплатную консультацию"
|
||||
description="Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для первичного правового анализа"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<!-- Левая колонка -->
|
||||
<div
|
||||
class="w-full lg:w-2/5 bg-gradient-to-br from-[var(--color-navy)] to-[#1a1f3d] p-10 lg:p-12 text-white relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-48 h-48 bg-[var(--color-blue-primary)] opacity-20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="block text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-4"
|
||||
>Свяжитесь с нами</span
|
||||
>
|
||||
<p class="text-gray-400 leading-relaxed mb-12">
|
||||
Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для
|
||||
первичного правового анализа.
|
||||
</p>
|
||||
|
||||
<div class="space-y-8">
|
||||
{
|
||||
contactInfo.map((item) => (
|
||||
<div class="flex items-start gap-4 group">
|
||||
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center flex-shrink-0 group-hover:bg-[var(--color-gold)] transition-colors">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d={contactIcons[item.icon]}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.href && item.type !== "phone" ? (
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : item.type === "phone" ? (
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors cursor-pointer text-left"
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
) : (
|
||||
<span class="text-lg font-bold">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка (Форма) -->
|
||||
<div class="w-full lg:w-3/5 p-10 lg:p-12 bg-white">
|
||||
<form class="space-y-8" id="consultation-form" novalidate>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Имя -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="name"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Ваше имя <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
pattern="[А-Яа-яЁёA-Za-z\s\-]+"
|
||||
placeholder="Иван Иванов"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите корректное имя (минимум 2 символа)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Телефон -->
|
||||
<div class="relative group">
|
||||
<label
|
||||
for="phone"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Телефон <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
pattern="\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span class="error-message hidden text-red-500 text-xs mt-1">
|
||||
Введите полный номер телефона
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сфера вопроса -->
|
||||
<div class="relative">
|
||||
<label
|
||||
for="practice"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Сфера вопроса
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
id="practice"
|
||||
name="practice"
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 appearance-none cursor-pointer transition-all"
|
||||
>
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
<option value={area.value}>{area.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщение -->
|
||||
<div class="relative">
|
||||
<label
|
||||
for="message"
|
||||
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Ваше сообщение <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
placeholder="Опишите ситуацию..."
|
||||
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 resize-none transition-all"
|
||||
></textarea>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="error-message hidden text-red-500 text-xs">
|
||||
Опишите ситуацию подробнее (минимум 10 символов)
|
||||
</span>
|
||||
<span class="char-count text-xs text-gray-400 ml-auto">
|
||||
0 / 1000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
disabled
|
||||
class="w-full py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white font-bold rounded-xl shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 hover:-translate-y-0.5 hover:cursor-pointer transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none"
|
||||
>
|
||||
<span class="btn-text">Отправить запрос</span>
|
||||
<span class="btn-loading hidden">Отправка...</span>
|
||||
</button>
|
||||
|
||||
<p class="text-center text-xs text-gray-400">
|
||||
Нажимая кнопку, вы соглашаетесь с{" "}
|
||||
<a
|
||||
href="/policy"
|
||||
class="text-[var(--color-gold)] hover:underline"
|
||||
>
|
||||
политикой конфиденциальности
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Типы для валидации
|
||||
type ValidationRule = {
|
||||
pattern?: RegExp;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const validationRules: Record<string, ValidationRule> = {
|
||||
name: {
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
pattern: /^[А-Яа-яЁёA-Za-z\s\-]+$/,
|
||||
},
|
||||
phone: {
|
||||
required: true,
|
||||
pattern: /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/,
|
||||
},
|
||||
message: {
|
||||
required: true,
|
||||
minLength: 10,
|
||||
maxLength: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
// Элементы формы
|
||||
const form = document.getElementById("consultation-form") as HTMLFormElement;
|
||||
const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement;
|
||||
const btnText = submitBtn.querySelector(".btn-text") as HTMLSpanElement;
|
||||
const btnLoading = submitBtn.querySelector(".btn-loading") as HTMLSpanElement;
|
||||
|
||||
// Отслеживаем, было ли поле в фокусе (для показа ошибок только после взаимодействия)
|
||||
const touchedFields = new Set<string>();
|
||||
|
||||
// Валидация поля
|
||||
function validateField(
|
||||
field: HTMLInputElement | HTMLTextAreaElement,
|
||||
showError: boolean = false,
|
||||
): boolean {
|
||||
const name = field.name;
|
||||
const rules = validationRules[name];
|
||||
const errorEl = field.parentElement?.querySelector(
|
||||
".error-message",
|
||||
) as HTMLElement;
|
||||
|
||||
if (!rules) return true;
|
||||
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
|
||||
// Проверка обязательности
|
||||
if (rules.required && !field.value.trim()) {
|
||||
isValid = false;
|
||||
errorMsg = "Обязательное поле";
|
||||
}
|
||||
// Проверка минимальной длины (только если поле не пустое)
|
||||
else if (
|
||||
rules.minLength &&
|
||||
field.value.length > 0 &&
|
||||
field.value.length < rules.minLength
|
||||
) {
|
||||
isValid = false;
|
||||
errorMsg = `Минимум ${rules.minLength} символов`;
|
||||
}
|
||||
// Проверка максимальной длины
|
||||
else if (rules.maxLength && field.value.length > rules.maxLength) {
|
||||
isValid = false;
|
||||
errorMsg = `Максимум ${rules.maxLength} символов`;
|
||||
}
|
||||
// Проверка паттерна (только если поле не пустое)
|
||||
else if (
|
||||
rules.pattern &&
|
||||
field.value.length > 0 &&
|
||||
!rules.pattern.test(field.value)
|
||||
) {
|
||||
isValid = false;
|
||||
errorMsg = "Некорректный формат";
|
||||
}
|
||||
|
||||
// Отображение ошибки только если поле было в фокусе или принудительный показ
|
||||
if (errorEl && (showError || touchedFields.has(name))) {
|
||||
if (!isValid && (field.value.length > 0 || showError)) {
|
||||
errorEl.textContent = errorMsg;
|
||||
errorEl.classList.remove("hidden");
|
||||
field.classList.add(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
field.classList.remove(
|
||||
"focus:border-[var(--color-gold)]",
|
||||
"focus:ring-[var(--color-gold)]/20",
|
||||
);
|
||||
} else {
|
||||
errorEl.classList.add("hidden");
|
||||
field.classList.remove(
|
||||
"border-red-500",
|
||||
"focus:border-red-500",
|
||||
"focus:ring-red-500/20",
|
||||
);
|
||||
field.classList.add(
|
||||
"focus:border-[var(--color-gold)]",
|
||||
"focus:ring-[var(--color-gold)]/20",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Проверка всей формы (без показа ошибок, только для активации кнопки)
|
||||
function checkFormValidity(): boolean {
|
||||
const fields = form.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>("input[required], textarea[required]");
|
||||
let isValid = true;
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!validateField(field, false)) isValid = false;
|
||||
});
|
||||
|
||||
submitBtn.disabled = !isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Показать все ошибки (при попытке отправки)
|
||||
function showAllErrors(): boolean {
|
||||
const fields = form.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>("input[required], textarea[required]");
|
||||
let isValid = true;
|
||||
|
||||
fields.forEach((field) => {
|
||||
touchedFields.add(field.name);
|
||||
if (!validateField(field, true)) isValid = false;
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Маска телефона с ограничением ввода
|
||||
const phoneInput = document.getElementById("phone") as HTMLInputElement;
|
||||
|
||||
phoneInput?.addEventListener("keypress", (e) => {
|
||||
// Разрешаем только цифры и управляющие клавиши
|
||||
if (
|
||||
!/\d/.test(e.key) &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
phoneInput?.addEventListener("input", (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
let value = target.value.replace(/\D/g, "");
|
||||
|
||||
// Ограничиваем длину
|
||||
if (value.length > 11) value = value.slice(0, 11);
|
||||
|
||||
// Убираем 7 или 8 в начале
|
||||
if (value.startsWith("7")) value = value.slice(1);
|
||||
if (value.startsWith("8")) value = value.slice(1);
|
||||
|
||||
// Форматируем
|
||||
let formatted = "+7";
|
||||
if (value.length > 0) formatted += " (" + value.slice(0, 3);
|
||||
if (value.length > 3) formatted += ") " + value.slice(3, 6);
|
||||
if (value.length > 6) formatted += "-" + value.slice(6, 8);
|
||||
if (value.length > 8) formatted += "-" + value.slice(8, 10);
|
||||
|
||||
target.value = formatted;
|
||||
validateField(target);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Ограничение ввода для имени (только буквы, пробелы, дефис)
|
||||
const nameInput = document.getElementById("name") as HTMLInputElement;
|
||||
|
||||
nameInput?.addEventListener("keypress", (e) => {
|
||||
if (
|
||||
!/[А-Яа-яЁёA-Za-z\s\-]/.test(e.key) &&
|
||||
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
nameInput?.addEventListener("input", () => {
|
||||
validateField(nameInput);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Счетчик символов для сообщения
|
||||
const messageInput = document.getElementById(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
const charCount = messageInput?.parentElement?.querySelector(
|
||||
".char-count",
|
||||
) as HTMLElement;
|
||||
|
||||
messageInput?.addEventListener("input", () => {
|
||||
const length = messageInput.value.length;
|
||||
if (charCount) {
|
||||
charCount.textContent = `${length} / 1000`;
|
||||
charCount.classList.toggle("text-red-500", length > 1000);
|
||||
}
|
||||
validateField(messageInput);
|
||||
checkFormValidity();
|
||||
});
|
||||
|
||||
// Отмечаем поле как "тронутое" при фокусе
|
||||
form.querySelectorAll("input, textarea").forEach((field) => {
|
||||
field.addEventListener("focus", () => {
|
||||
touchedFields.add((field as HTMLInputElement).name);
|
||||
});
|
||||
});
|
||||
|
||||
// Валидация при потере фокуса (показываем ошибки только если поле было заполнено неверно)
|
||||
form.querySelectorAll("input, textarea").forEach((field) => {
|
||||
field.addEventListener("blur", () => {
|
||||
const input = field as HTMLInputElement;
|
||||
touchedFields.add(input.name);
|
||||
// Показываем ошибку только если поле не пустое и невалидно, или если пытались отправить
|
||||
if (input.value.length > 0) {
|
||||
validateField(input, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Отправка формы
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Показываем все ошибки при попытке отправки
|
||||
if (!showAllErrors()) return;
|
||||
|
||||
// Блокировка кнопки
|
||||
submitBtn.disabled = true;
|
||||
btnText.classList.add("hidden");
|
||||
btnLoading.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// Отправка на API
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "Ошибка при отправке");
|
||||
}
|
||||
|
||||
// Успех
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(
|
||||
"Заявка успешно отправлена! Мы свяжемся с вами в ближайшее время.",
|
||||
"success",
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
touchedFields.clear();
|
||||
checkFormValidity(); // Сброс состояния кнопки
|
||||
|
||||
// Сброс счётчика символов
|
||||
if (charCount) {
|
||||
charCount.textContent = "0 / 1000";
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ContactForm] Ошибка:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Ошибка при отправке. Попробуйте позже.";
|
||||
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(errorMessage, "error", 5000);
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
btnText.classList.remove("hidden");
|
||||
btnLoading.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Начальная проверка (без показа ошибок)
|
||||
checkFormValidity();
|
||||
</script>
|
||||
107
frontend/src/components/base/CtaBlock.astro
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
export interface Props {
|
||||
// Тексты и ссылки
|
||||
title?: string;
|
||||
description?: string;
|
||||
primaryBtnText?: string;
|
||||
primaryBtnLink?: string;
|
||||
secondaryBtnText?: string;
|
||||
secondaryBtnLink?: string;
|
||||
|
||||
// Цвета фона
|
||||
bgColor?: string; // Основной фон секции (tailwind класс или hex)
|
||||
gridOpacity?: number; // Прозрачность сетки (0-1)
|
||||
|
||||
// Стили primary кнопки
|
||||
primaryBtnBg?: string; // Фон кнопки
|
||||
primaryBtnHover?: string; // Фон при наведении
|
||||
primaryBtnTextColor?: string; // Цвет текста
|
||||
|
||||
// Стили secondary кнопки
|
||||
secondaryBtnBg?: string; // Фон кнопки
|
||||
secondaryBtnHover?: string; // Фон при наведении
|
||||
secondaryBtnTextColor?: string; // Цвет текста
|
||||
secondaryBtnBorder?: string; // Цвет бордера (опционально)
|
||||
|
||||
// Дополнительно
|
||||
showGrid?: boolean; // Показывать сетку?
|
||||
gradientOverlay?: boolean; // Градиент сверху?
|
||||
rounded?: string; // Скругление углов
|
||||
}
|
||||
|
||||
const {
|
||||
// Контент
|
||||
title = "Нужна помощь эксперта?",
|
||||
description = "Запишитесь на первичную консультацию, и мы вместе найдем выход из вашей юридической ситуации.",
|
||||
primaryBtnText = "Записаться на прием",
|
||||
primaryBtnLink = "#",
|
||||
secondaryBtnText = "Связаться в Telegram",
|
||||
secondaryBtnLink = "#",
|
||||
|
||||
// Цвета по умолчанию (как в оригинале)
|
||||
bgColor = "bg-[#444ce7]",
|
||||
gridOpacity = 0.1,
|
||||
|
||||
// Primary кнопка (золотая по умолчанию)
|
||||
primaryBtnBg = "bg-[#cbb059]",
|
||||
primaryBtnHover = "hover:bg-[#bfa34d]",
|
||||
primaryBtnTextColor = "text-[#1a1f2e]",
|
||||
|
||||
// Secondary кнопка (полупрозрачная по умолчанию)
|
||||
secondaryBtnBg = "bg-[#565df0]",
|
||||
secondaryBtnHover = "hover:bg-[#646af3]",
|
||||
secondaryBtnTextColor = "text-white",
|
||||
secondaryBtnBorder = "border-white/20",
|
||||
|
||||
// Оформление
|
||||
showGrid = true,
|
||||
gradientOverlay = true,
|
||||
rounded = "rounded-3xl"
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`relative w-full ${bgColor} ${rounded} overflow-hidden shadow-2xl my-8`}>
|
||||
|
||||
{/* Декоративная сетка */}
|
||||
{showGrid && (
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style={`opacity: ${gridOpacity}; background-image: linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px); background-size: 40px 40px;`}
|
||||
>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Градиент */}
|
||||
{gradientOverlay && (
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-black/10 pointer-events-none"></div>
|
||||
)}
|
||||
|
||||
{/* Контент */}
|
||||
<div class="relative z-10 py-12 px-6 md:py-16 md:px-12 text-center">
|
||||
<h3 class="text-2xl md:text-4xl font-bold text-white mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p class="text-white/80 text-sm md:text-base max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
||||
{/* Primary кнопка */}
|
||||
<a
|
||||
href={primaryBtnLink}
|
||||
class={`${primaryBtnBg} ${primaryBtnHover} ${primaryBtnTextColor} font-bold py-3 px-8 rounded-lg transition-all duration-300 shadow-lg inline-block text-center hover:-translate-y-0.5`}
|
||||
>
|
||||
{primaryBtnText}
|
||||
</a>
|
||||
|
||||
{/* Secondary кнопка */}
|
||||
<a
|
||||
href={secondaryBtnLink}
|
||||
class={`${secondaryBtnBg} ${secondaryBtnHover} ${secondaryBtnTextColor} border ${secondaryBtnBorder} font-semibold py-3 px-8 rounded-lg transition-all duration-300 flex items-center justify-center gap-2 hover:-translate-y-0.5`}
|
||||
>
|
||||
{secondaryBtnText}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
10
frontend/src/components/base/Favicon.astro
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
// Компонент Favicon.astro
|
||||
// Содержит все необходимые теги для фавиконов
|
||||
---
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||
160
frontend/src/components/base/Map.astro
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from '@constants/constants.ts';
|
||||
|
||||
interface Props {
|
||||
variant?: 'full' | 'card' | 'simple';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
address?: string;
|
||||
mapUrl?: string;
|
||||
showRouteButton?: boolean;
|
||||
lazyLoad?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'full',
|
||||
title = "Наш офис",
|
||||
subtitle = "г. Сургут, пр. Комсомольский, 19",
|
||||
address = CONTACT_CONSTANTS.address,
|
||||
mapUrl = "https://yandex.ru/maps/-/CDu~yK-j",
|
||||
showRouteButton = true,
|
||||
lazyLoad = true
|
||||
} = Astro.props;
|
||||
|
||||
const defaultMapUrl = "https://yandex.ru/map-widget/v1/?um=constructor%3Acdxezk6x&source=constructor";
|
||||
const currentMapUrl = variant === 'card' ? defaultMapUrl : mapUrl;
|
||||
---
|
||||
|
||||
{variant === 'full' ? (
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl mb-12">
|
||||
<section id="map-section" class="relative w-full h-[500px] overflow-hidden rounded-3xl shadow-2xl">
|
||||
<div class="absolute inset-0 bg-gray-200">
|
||||
<iframe
|
||||
data-src={currentMapUrl}
|
||||
class="map-iframe w-full h-full border-0 grayscale contrast-125 opacity-0 transition-opacity duration-700"
|
||||
allowfullscreen
|
||||
loading={lazyLoad ? "lazy" : "eager"}
|
||||
title="Карта проезда"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-white/95 backdrop-blur-xl p-8 md:p-10 rounded-2xl shadow-2xl max-w-sm text-center pointer-events-auto border border-white/50 mx-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-gray-900 font-bold text-lg uppercase tracking-wider mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 mb-6 leading-relaxed">
|
||||
{address}
|
||||
</p>
|
||||
|
||||
{showRouteButton && (
|
||||
<a
|
||||
href={currentMapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-gray-900 text-white text-sm font-bold uppercase tracking-wider rounded-xl hover:bg-[var(--color-gold)] transition-colors shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Открыть в картах
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : variant === 'card' ? (
|
||||
<div class="relative bg-white/80 backdrop-blur-xl border border-white/50 rounded-3xl overflow-hidden shadow-2xl shadow-gray-900/5 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 transition-all duration-500 group">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-gray-100/80 pointer-events-none z-10"></div>
|
||||
|
||||
<div class="relative z-20 p-8 pb-0 flex flex-col items-center text-center md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-2 flex items-center gap-3 justify-center md:justify-start">
|
||||
<span class="w-2 h-6 sm:h-8 bg-gradient-to-b from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full hidden md:block"></span>
|
||||
{title}
|
||||
</h2>
|
||||
<p class="text-gray-600 text-base sm:text-lg flex items-center gap-2 justify-center md:justify-start">
|
||||
<svg class="w-4 sm:w-5 h-4 sm:h-5 text-[var(--color-gold)] hidden md:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
</svg>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showRouteButton && (
|
||||
<a href={currentMapUrl} target="_blank" class="inline-flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-[var(--color-gold)]/10 hover:bg-[var(--color-gold)]/20 text-[var(--color-gold)] font-semibold rounded-xl transition-all duration-300 border border-[var(--color-gold)]/20 hover:border-[var(--color-gold)]/40 self-center md:self-auto">
|
||||
<span class="text-sm sm:text-base">Маршрут</span>
|
||||
<svg class="w-4 sm:w-5 h-4 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 20L20 4" opacity="0.2" stroke-width="1" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="relative h-[450px] mt-8 bg-gray-100 overflow-hidden">
|
||||
<iframe
|
||||
src={currentMapUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
class="filter grayscale-[30%] contrast-125 group-hover:grayscale-0 transition-all duration-700"
|
||||
loading={lazyLoad ? "lazy" : "eager"}
|
||||
title="Офис на карте Сургута"
|
||||
></iframe>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-gray-900/0 group-hover:bg-gray-900/0 transition-all duration-500 pointer-events-none">
|
||||
<span class="px-4 sm:px-6 py-2 sm:py-3 bg-white/95 backdrop-blur-sm text-gray-900 rounded-full text-xs sm:text-sm font-bold border border-gray-200 shadow-xl transform translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-500 pointer-events-auto cursor-pointer hover:bg-[var(--color-gold)] hover:text-white hover:border-[var(--color-gold)]">
|
||||
Наведите для взаимодействия
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="relative h-[400px] bg-gray-100 overflow-hidden rounded-xl">
|
||||
<iframe
|
||||
src={currentMapUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
loading={lazyLoad ? "lazy" : "eager"}
|
||||
title="Карта"
|
||||
></iframe>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lazyLoad && variant === 'full' && (
|
||||
<script>
|
||||
const mapSection = document.getElementById('map-section');
|
||||
const iframe = mapSection?.querySelector('.map-iframe');
|
||||
|
||||
if (mapSection && iframe) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const frame = entry.target.querySelector('iframe');
|
||||
if (frame && frame.dataset.src) {
|
||||
frame.src = frame.dataset.src;
|
||||
frame.onload = () => {
|
||||
frame.classList.remove('opacity-0');
|
||||
frame.classList.add('opacity-100');
|
||||
};
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '200px', threshold: 0.1 });
|
||||
|
||||
observer.observe(mapSection);
|
||||
}
|
||||
</script>
|
||||
)}
|
||||
192
frontend/src/components/base/MapFull.astro
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
---
|
||||
import SectionHeader from "@components/base/SectionHeader.astro";
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
interface Props {
|
||||
city?: string;
|
||||
title?: string;
|
||||
address?: string;
|
||||
mapUrl?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
city = "СУРГУТЕ",
|
||||
title = "Наш офис",
|
||||
address = CONTACT_CONSTANTS.address,
|
||||
mapUrl = "https://yandex.ru/map-widget/v1/?um=constructor%3Acdxezk6x&source=constructor",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="py-16 bg-gradient-to-b from-gray-50 to-white -mx-4 md:mx-0">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
Как нас <span class="text-[var(--color-gold)]">найти</span>
|
||||
</h2>
|
||||
<p class="text-gray-600 max-w-2xl mx-auto">
|
||||
Мы находимся в центре города, удобный подъезд и парковка
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Карта с информационной карточкой -->
|
||||
<div class="relative bg-white rounded-3xl shadow-2xl overflow-hidden">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3">
|
||||
<!-- Информационная панель -->
|
||||
<div
|
||||
class="bg-[var(--color-navy)] p-8 flex flex-col justify-center text-center lg:text-left"
|
||||
>
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="w-14 h-14 bg-[var(--color-gold)] rounded-xl flex items-center justify-center mx-auto lg:mx-0 mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-white font-bold text-xl uppercase tracking-wider mb-2"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p class="text-gray-400 text-sm">{city}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 text-center lg:text-left">
|
||||
<div
|
||||
class="flex flex-col items-center lg:flex-row lg:items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] mt-1 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs uppercase tracking-wider mb-1">
|
||||
Адрес
|
||||
</p>
|
||||
<p class="text-white font-medium">{address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center lg:flex-row lg:items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] mt-1 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs uppercase tracking-wider mb-1">
|
||||
Режим работы
|
||||
</p>
|
||||
<p class="text-white font-medium">Пн-Пт: 09:00 - 18:00</p>
|
||||
<p class="text-gray-500 text-sm">Сб-Вс: По записи</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center lg:flex-row lg:items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] mt-1 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs uppercase tracking-wider mb-1">
|
||||
Телефон
|
||||
</p>
|
||||
<a
|
||||
href={CONTACT_CONSTANTS.phoneHref}
|
||||
class="text-white font-medium hover:text-[var(--color-gold)] transition-colors"
|
||||
>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={mapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-8 inline-flex items-center justify-center gap-2 px-6 py-3 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-white font-bold rounded-xl transition-all w-full lg:w-auto"
|
||||
>
|
||||
<span>Построить маршрут</span>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Карта -->
|
||||
<div class="lg:col-span-2 relative h-[400px] lg:h-auto">
|
||||
<iframe
|
||||
src={mapUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style="border:0; min-height: 400px;"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
title="Карта проезда"
|
||||
class="absolute inset-0 w-full h-full"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
7
frontend/src/components/base/Modal.astro
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1 class="font-bold text-center">Modal window</h1>
|
||||
</Layout>
|
||||
84
frontend/src/components/base/PageTitle.astro
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badgeText?: string;
|
||||
highlightText?: string;
|
||||
highlightInTitle?: boolean; // Если true, то highlightText будет частью заголовка, иначе - частью subtitle
|
||||
centered?: boolean;
|
||||
titleColor?: 'white' | 'gray-900'; // Цвет заголовка, по умолчанию 'white' для темных фонов и 'gray-900' для светлых
|
||||
subtitleColor?: 'gray-400' | 'gray-600'; // Цвет подзаголовка
|
||||
highlightInSubtitle?: boolean; // Если true, то highlightText будет частью подзаголовка с особым оформлением
|
||||
contactsFormat?: boolean; // Если true, то используется специальный формат для контактов с выделением в подзаголовке
|
||||
titleSize?: 'normal' | 'large'; // Размер заголовка: normal (text-4xl md:text-5xl) или large (text-3xl sm:text-4xl md:text-6xl lg:text-8xl)
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
badgeText = "Мнение клиентов",
|
||||
highlightText,
|
||||
highlightInTitle = true, // по умолчанию highlightText будет частью заголовка
|
||||
centered = true,
|
||||
titleColor = 'white',
|
||||
subtitleColor = 'gray-400',
|
||||
highlightInSubtitle = false,
|
||||
contactsFormat = false,
|
||||
titleSize = 'large'
|
||||
} = Astro.props;
|
||||
|
||||
// Определение классов цветов
|
||||
const titleTextColor = titleColor === 'white' ? '[var(--color-white)]' : 'gray-900';
|
||||
const subtitleTextColor = subtitleColor === 'gray-400' ? '[var(--color-gray-400)]' : 'gray-600';
|
||||
---
|
||||
|
||||
<div class={`mb-12 ${centered ? 'text-center' : ''}`}>
|
||||
{badgeText && (
|
||||
<div class="flex items-center justify-center gap-2 px-4 py-2 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider mb-6 w-fit mx-auto">
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rounded-full animate-pulse"></span>
|
||||
{badgeText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlightInTitle ? (
|
||||
<h1 class={`${titleSize === 'large' ? 'text-3xl sm:text-4xl md:text-6xl lg:text-8xl' : 'text-4xl md:text-5xl'} font-bold tracking-tight text-${titleTextColor} mb-6 relative inline-block`}>
|
||||
{title}
|
||||
{highlightText && <span class="text-[var(--color-gold)]">{highlightText}</span>}
|
||||
{titleSize === 'large' && (
|
||||
<span class="absolute -bottom-3 sm:-bottom-4 left-1/2 -translate-x-1/2 w-16 sm:w-24 h-1 bg-gradient-to-r from-transparent via-[var(--color-gold)] to-transparent rounded-full"></span>
|
||||
)}
|
||||
</h1>
|
||||
) : (
|
||||
<h1 class={`${titleSize === 'large' ? 'text-3xl sm:text-4xl md:text-6xl lg:text-8xl' : 'text-4xl md:text-5xl'} font-bold tracking-tight text-${titleTextColor} mb-6 relative inline-block`}>
|
||||
{title}
|
||||
{titleSize === 'large' && (
|
||||
<span class="absolute -bottom-3 sm:-bottom-4 left-1/2 -translate-x-1/2 w-16 sm:w-24 h-1 bg-gradient-to-r from-transparent via-[var(--color-gold)] to-transparent rounded-full"></span>
|
||||
)}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<p class={`text-${subtitleTextColor} ${centered ? 'max-w-2xs sm:max-w-3xl mx-auto' : ''} text-base sm:text-lg md:text-xl lg:text-2xl leading-relaxed font-light`}>
|
||||
{subtitle}
|
||||
{contactsFormat && !highlightInTitle && highlightText ? (
|
||||
<span class="relative inline-block mx-1 sm:mx-2 mt-2 block sm:inline">
|
||||
<span class="relative z-10 text-[var(--color-gold)] font-bold bg-gradient-to-r from-[var(--color-gold)]/20 to-[var(--color-gold-hover)]/20 px-2 sm:px-3 py-0.5 sm:py-1 rounded-lg sm:rounded-xl border border-[var(--color-gold)]/20 text-sm sm:text-base">
|
||||
{highlightText}
|
||||
</span>
|
||||
</span>
|
||||
) : (!highlightInTitle && highlightText && highlightInSubtitle) ? (
|
||||
<span class="relative inline-block mx-1 sm:mx-2 mt-2 block sm:inline">
|
||||
<span class="relative z-10 text-[var(--color-gold)] font-bold bg-gradient-to-r from-[var(--color-gold)]/20 to-[var(--color-gold-hover)]/20 px-2 sm:px-3 py-0.5 sm:py-1 rounded-lg sm:rounded-xl border border-[var(--color-gold)]/20 text-sm sm:text-base">
|
||||
{highlightText}
|
||||
</span>
|
||||
</span>
|
||||
) : (!highlightInTitle && highlightText && !highlightInSubtitle) ? (
|
||||
<span class="relative inline-block mx-1 sm:mx-2 mt-2 block sm:inline">
|
||||
<span class="relative z-10 text-[var(--color-gold)] font-bold bg-gradient-to-r from-[var(--color-gold)]/20 to-[var(--color-gold-hover)]/20 px-2 sm:px-3 py-0.5 sm:py-1 rounded-lg sm:rounded-xl border border-[var(--color-gold)]/20 text-sm sm:text-base">
|
||||
{highlightText}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
17
frontend/src/components/base/Pagination.astro
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<div class="flex justify-center items-center space-x-2 mt-8">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors hover:cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-lg bg-[var(--color-blue-primary)] text-white font-bold shadow-md transition-colors">1</button>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 font-medium transition-colors">2</button>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 font-medium transition-colors">3</button>
|
||||
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors hover:cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
75
frontend/src/components/base/ScrollTopButton.astro
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
|
||||
<button
|
||||
id="scroll-top-button"
|
||||
class="fixed bottom-8 right-8 z-[100] w-12 h-12 bg-[#bf9b58] text-white rounded-full shadow-lg flex items-center justify-center opacity-0 invisible transition-all duration-300 hover:bg-[#a68545] hover:cursor-pointer group"
|
||||
aria-label="Вернуться наверх"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 transition-transform duration-300 group-hover:-translate-y-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#scroll-top-button.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Функция для обработки прокрутки
|
||||
const handleScroll = () => {
|
||||
const scrollTopButton = document.getElementById('scroll-top-button');
|
||||
|
||||
if (!scrollTopButton) return;
|
||||
|
||||
// Показываем кнопку, если прокрутили больше 20% высоты viewport
|
||||
const scrollPercentage = window.scrollY / window.innerHeight;
|
||||
|
||||
if (scrollPercentage > 0.2) {
|
||||
scrollTopButton.classList.add('show');
|
||||
} else {
|
||||
scrollTopButton.classList.remove('show');
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для прокрутки наверх
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// Добавляем обработчики событий
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('scroll-top-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', scrollToTop);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
|
||||
// Убираем обработчики при изменении страницы в Astro
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
const button = document.getElementById('scroll-top-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', scrollToTop);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
</script>
|
||||
71
frontend/src/components/base/SectionHeader.astro
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
interface Props {
|
||||
label?: string;
|
||||
title: string;
|
||||
highlight?: string;
|
||||
description?: string;
|
||||
align?: 'center' | 'left';
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const {
|
||||
label = '',
|
||||
title,
|
||||
highlight = '',
|
||||
description = '',
|
||||
align = 'center',
|
||||
theme = 'light'
|
||||
} = Astro.props;
|
||||
|
||||
const alignClasses = {
|
||||
center: 'text-center',
|
||||
left: 'text-left'
|
||||
};
|
||||
|
||||
const themeClasses = {
|
||||
light: {
|
||||
label: 'bg-[var(--color-gold)]/10 text-[var(--color-gold)]',
|
||||
title: 'text-gray-900',
|
||||
highlight: 'text-[var(--color-blue-primary)]',
|
||||
description: 'text-gray-600'
|
||||
},
|
||||
dark: {
|
||||
label: 'bg-white/10 text-[var(--color-gold)]',
|
||||
title: 'text-white',
|
||||
highlight: 'text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)]',
|
||||
description: 'text-gray-400'
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`mb-16 ${alignClasses[align]}`}>
|
||||
{label && (
|
||||
<span class={`inline-block px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-full mb-4 ${themeClasses[theme].label}`}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{highlight ? (
|
||||
<h2 class={`text-4xl md:text-5xl font-bold ${themeClasses[theme].title} leading-tight`}>
|
||||
{title.split(highlight).map((part, i) => (
|
||||
<>
|
||||
{part}{i === 0 && <span class={themeClasses[theme].highlight}>{highlight}</span>}
|
||||
</>
|
||||
))}
|
||||
</h2>
|
||||
) : (
|
||||
<h2 class={`text-4xl md:text-5xl font-bold ${themeClasses[theme].title} leading-tight`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{theme === 'light' && (
|
||||
<div class="w-24 h-1.5 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] mx-auto rounded-full my-6"></div>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p class={`mt-6 max-w-2xl ${align === 'center' ? 'mx-auto' : ''} ${themeClasses[theme].description} leading-relaxed`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
93
frontend/src/components/base/SocialIcons.astro
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
interface Props {
|
||||
variant?: 'footer' | 'messenger';
|
||||
className?: string;
|
||||
whatsapp?: boolean;
|
||||
imo?: boolean;
|
||||
}
|
||||
|
||||
const { variant = 'footer', className = '', whatsapp, imo = false } = Astro.props;
|
||||
|
||||
// Устанавливаем значения по умолчанию в зависимости от варианта
|
||||
const showWhatsapp = whatsapp !== undefined ? whatsapp : (variant === 'footer');
|
||||
|
||||
const baseClasses = "flex gap-3";
|
||||
|
||||
const linkClasses = {
|
||||
footer: "w-10 h-10 bg-[#1e2532] flex items-center justify-center rounded-sm hover:bg-[#bf9b58] hover:text-[#151b26] transition-all duration-300 group",
|
||||
messenger: "w-12 h-12 rounded-xl bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 flex items-center justify-center text-[var(--color-gray-500)] hover:text-[#229ED9] hover:border-[#229ED9]/30 hover:bg-[#229ED9]/10 transition-all"
|
||||
};
|
||||
|
||||
const currentLinkClass = linkClasses[variant];
|
||||
---
|
||||
|
||||
<div class={`${baseClasses} ${className}`}>
|
||||
{variant === 'footer' ? (
|
||||
<>
|
||||
<!-- Telegram -->
|
||||
<a href="https://t.me/advokat086" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="Telegram">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{/* WhatsApp */}
|
||||
{showWhatsapp && (
|
||||
<a href="https://wa.me/79222538375" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="WhatsApp">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* IMO */}
|
||||
{imo && (
|
||||
<a href="https://imo.im" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="IMO">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor"/>
|
||||
<circle cx="6.5" cy="12" r="2.5" fill="#151b26"/>
|
||||
<circle cx="12" cy="12" r="2.5" fill="#151b26"/>
|
||||
<circle cx="17.5" cy="12" r="2.5" fill="#151b26"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<!-- Email -->
|
||||
<a href="mailto:info@advokat086.ru" class={currentLinkClass} aria-label="Email">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<!-- Telegram -->
|
||||
<a href="https://t.me/advokat086" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="Telegram">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{/* WhatsApp */}
|
||||
{showWhatsapp && (
|
||||
<a href="https://wa.me/79222538375" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="WhatsApp">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* IMO */}
|
||||
{imo && (
|
||||
<a href="https://imo.im" target="_blank" rel="noopener noreferrer" class={currentLinkClass} aria-label="IMO">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor"/>
|
||||
<circle cx="6.5" cy="12" r="2.5" fill="#0f172a"/>
|
||||
<circle cx="12" cy="12" r="2.5" fill="#0f172a"/>
|
||||
<circle cx="17.5" cy="12" r="2.5" fill="#0f172a"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
228
frontend/src/components/base/Toast.astro
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
interface Props {
|
||||
message?: string;
|
||||
type?: "error" | "success" | "warning" | "info";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const { message = "", type = "error", duration = 3000 } = Astro.props;
|
||||
|
||||
// Серверная конфигурация (используется для начального рендера)
|
||||
const typeConfig = {
|
||||
error: {
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-500",
|
||||
icon: "text-red-500",
|
||||
iconPath:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
success: {
|
||||
bg: "bg-green-50",
|
||||
border: "border-green-500",
|
||||
icon: "text-green-500",
|
||||
iconPath:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-yellow-50",
|
||||
border: "border-yellow-500",
|
||||
icon: "text-yellow-500",
|
||||
iconPath:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />',
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50",
|
||||
border: "border-blue-500",
|
||||
icon: "text-blue-500",
|
||||
iconPath:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
};
|
||||
|
||||
const config = typeConfig[type];
|
||||
---
|
||||
|
||||
<div
|
||||
id="toast-notification"
|
||||
class="fixed top-4 left-0 right-0 mx-auto z-9999 min-w-[320px] max-w-md flex justify-center"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
id="toast-content"
|
||||
class={`${config.bg} border-l-4 ${config.border} rounded-lg shadow-2xl p-4 flex items-start gap-3 transition-all duration-300`}
|
||||
>
|
||||
<div
|
||||
id="toast-icon-container"
|
||||
class={`${config.icon} shrink-0 transition-colors duration-300`}
|
||||
>
|
||||
<svg
|
||||
id="toast-icon-svg"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<Fragment set:html={config.iconPath} />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-800 text-sm font-medium flex-1" id="toast-message">
|
||||
{message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="toast-close"
|
||||
class="shrink-0 text-gray-400 hover:text-gray-600 cursor-pointer transition-colors"
|
||||
aria-label="Закрыть уведомление"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
const toast = document.getElementById("toast-notification");
|
||||
const toastContent = document.getElementById("toast-content");
|
||||
const toastIconContainer = document.getElementById("toast-icon-container");
|
||||
const toastIconSvg = document.getElementById("toast-icon-svg");
|
||||
const toastMessage = document.getElementById("toast-message");
|
||||
const toastClose = document.getElementById("toast-close");
|
||||
let timeoutId;
|
||||
|
||||
// Клиентская конфигурация для динамического изменения стилей
|
||||
const clientTypeConfig = {
|
||||
error: {
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-500",
|
||||
icon: "text-red-500",
|
||||
path: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
success: {
|
||||
bg: "bg-green-50",
|
||||
border: "border-green-500",
|
||||
icon: "text-green-500",
|
||||
path: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-yellow-50",
|
||||
border: "border-yellow-500",
|
||||
icon: "text-yellow-500",
|
||||
path: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />',
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50",
|
||||
border: "border-blue-500",
|
||||
icon: "text-blue-500",
|
||||
path: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
},
|
||||
};
|
||||
|
||||
function updateToastStyles(type) {
|
||||
if (!toastContent || !toastIconContainer || !toastIconSvg) return;
|
||||
|
||||
const config = clientTypeConfig[type] || clientTypeConfig.error;
|
||||
|
||||
// Очищаем старые классы (оставляем только базовые)
|
||||
toastContent.className = `border-l-4 rounded-lg shadow-lg p-4 flex items-start gap-3 transition-colors duration-300 ${config.bg} ${config.border}`;
|
||||
toastIconContainer.className = `flex-shrink-0 transition-colors duration-300 ${config.icon}`;
|
||||
|
||||
// Меняем иконку
|
||||
toastIconSvg.innerHTML = config.path;
|
||||
}
|
||||
|
||||
function showToast(message, type = "error", duration = 3000) {
|
||||
console.log("[TOAST] Вызов showToast:", { message, type, duration });
|
||||
console.log("[TOAST] Элементы:", {
|
||||
toast: !!toast,
|
||||
toastMessage: !!toastMessage,
|
||||
});
|
||||
|
||||
// Очищаем предыдущий таймер
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Устанавливаем сообщение и обновляем стили
|
||||
if (toastMessage) toastMessage.textContent = message;
|
||||
updateToastStyles(type);
|
||||
|
||||
// Показываем тост с анимацией
|
||||
if (toast) {
|
||||
console.log("[TOAST] Показываем toast, hidden был:", toast.hidden);
|
||||
console.log(
|
||||
"[TOAST] computed style до изменений:",
|
||||
window.getComputedStyle(toast).display,
|
||||
);
|
||||
|
||||
toast.hidden = false;
|
||||
toast.style.display = "flex"; // Явно показываем
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateY(-20px)";
|
||||
toast.style.transition = "opacity 0.3s ease, transform 0.3s ease";
|
||||
|
||||
// Форсируем перерисовку браузера (чтобы анимация сработала)
|
||||
toast.offsetHeight;
|
||||
console.log(
|
||||
"[TOAST] После offsetHeight, hidden:",
|
||||
toast.hidden,
|
||||
"display:",
|
||||
toast.style.display,
|
||||
);
|
||||
|
||||
// Анимация появления
|
||||
toast.style.opacity = "1";
|
||||
toast.style.transform = "translateY(0)";
|
||||
|
||||
console.log("[TOAST] Toast показан, opacity:", toast.style.opacity);
|
||||
console.log(
|
||||
"[TOAST] computed style после:",
|
||||
window.getComputedStyle(toast).display,
|
||||
window.getComputedStyle(toast).opacity,
|
||||
);
|
||||
|
||||
// Автоматическое скрытие
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log("[TOAST] Скрываем по таймеру");
|
||||
hideToast();
|
||||
}, duration);
|
||||
} else {
|
||||
console.error("[TOAST] Элемент toast не найден!");
|
||||
}
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (toast) {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateY(-20px)";
|
||||
|
||||
setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Закрытие по кнопке
|
||||
if (toastClose) {
|
||||
toastClose.addEventListener("click", hideToast);
|
||||
}
|
||||
|
||||
// Делаем функции доступными глобально для вызова из других компонентов (например, из LoginForm)
|
||||
window.showToast = showToast;
|
||||
window.hideToast = hideToast;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#toast-notification[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/components/blog/BlogSection.astro
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
---
|
||||
import PostCard from "./PostCard.astro";
|
||||
import Pagination from "../base/Pagination.astro";
|
||||
import SidebarSearch from "./SidebarSearch.astro";
|
||||
import SidebarPopular from "./SidebarPopular.astro";
|
||||
import SidebarCategories from "./SidebarCategories.astro";
|
||||
import TagCloud from "./TagCloud.astro";
|
||||
import { MONTHS } from "@lib/constants";
|
||||
|
||||
const POCKETBASE_URL =
|
||||
import.meta.env.POCKETBASE_URL || "http://localhost:8090";
|
||||
|
||||
interface PostRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
image?: string;
|
||||
category?: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PocketBaseResponse {
|
||||
items: PostRecord[];
|
||||
totalItems?: number;
|
||||
totalPages?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
// Параметры пагинации
|
||||
const page = parseInt(Astro.url.searchParams.get("page") || "1");
|
||||
const perPage = 44; // 22 ряда по 2 карточки
|
||||
|
||||
// Загружаем посты из PocketBase с пагинацией
|
||||
let posts: Array<{
|
||||
id: string;
|
||||
image: string;
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
slug: string;
|
||||
}> = [];
|
||||
|
||||
let totalPages = 1;
|
||||
let currentPage = page;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${POCKETBASE_URL}/api/collections/posts/records?page=${page}&perPage=${perPage}&sort=-created`,
|
||||
);
|
||||
const data: PocketBaseResponse = await response.json();
|
||||
|
||||
if (data.items) {
|
||||
posts = data.items.map((post) => {
|
||||
const date = new Date(post.created);
|
||||
const formattedDate = `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()} года`;
|
||||
|
||||
// Формируем URL изображения
|
||||
const imageUrl = post.image
|
||||
? `${POCKETBASE_URL}/api/files/posts/${post.id}/${post.image}`
|
||||
: "https://images.unsplash.com/photo-1556761175-5973dc0f32e7?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80";
|
||||
|
||||
// Берём категорию из нового поля category
|
||||
const category =
|
||||
post.category && post.category.trim() !== ""
|
||||
? post.category
|
||||
: "НОВОСТИ";
|
||||
|
||||
return {
|
||||
id: post.id,
|
||||
image: imageUrl,
|
||||
category,
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
date: formattedDate,
|
||||
slug: `/blog/${post.slug}`,
|
||||
};
|
||||
});
|
||||
|
||||
totalPages = data.totalPages || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching posts from PocketBase:", error);
|
||||
}
|
||||
|
||||
const popularPosts = [
|
||||
{
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1556761175-5973dc0f32e7?w=200&h=200&fit=crop&q=80",
|
||||
title: "Как обжаловать решение суда первой инстанции",
|
||||
views: "2.4K просмотров",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1589829085413-56de8ae18c73?w=200&h=200&fit=crop&q=80",
|
||||
title: "Снижение кадастровой стоимости в Сургуте",
|
||||
views: "1.8K просмотров",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1569336415962-a4bd9f69cd83?w=200&h=200&fit=crop&q=80",
|
||||
title: "Защита прав потребителей при покупке авто",
|
||||
views: "1.5K просмотров",
|
||||
},
|
||||
];
|
||||
|
||||
// Загружаем категории из PocketBase динамически
|
||||
interface CategoryItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
let categories: CategoryItem[] = [];
|
||||
|
||||
try {
|
||||
// Получаем все посты для подсчёта категорий
|
||||
const allPostsResponse = await fetch(
|
||||
`${POCKETBASE_URL}/api/collections/posts/records?perPage=500`,
|
||||
);
|
||||
const allPostsData: PocketBaseResponse = await allPostsResponse.json();
|
||||
|
||||
if (allPostsData.items) {
|
||||
const categoryMap = new Map<string, number>();
|
||||
|
||||
allPostsData.items.forEach((post) => {
|
||||
const category =
|
||||
post.category && post.category.trim() !== ""
|
||||
? post.category
|
||||
: "НОВОСТИ";
|
||||
const currentCount = categoryMap.get(category) || 0;
|
||||
categoryMap.set(category, currentCount + 1);
|
||||
});
|
||||
|
||||
// Преобразуем в массив и сортируем по количеству (убывание)
|
||||
categories = Array.from(categoryMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6); // Берём максимум 6 категорий
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching categories from PocketBase:", error);
|
||||
// Fallback на пустой массив
|
||||
categories = [];
|
||||
}
|
||||
---
|
||||
|
||||
<div class="relative w-full py-12 md:py-16 overflow-hidden">
|
||||
<!-- Контейнер с правильными отступами -->
|
||||
<div class="relative z-10 w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="flex flex-col items-center text-center mb-8 md:mb-12">
|
||||
<div class="flex flex-col items-center mb-4 md:mb-6">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-2">
|
||||
Все публикации
|
||||
</h2>
|
||||
<div
|
||||
class="w-20 h-1 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/blog/archive"
|
||||
class="inline-flex items-center gap-2 text-[var(--color-blue-primary)] font-semibold hover:text-[var(--color-gold)] transition-colors group"
|
||||
>
|
||||
Архив статей
|
||||
<svg
|
||||
class="w-4 h-4 transform group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 w-full">
|
||||
<!-- Основной контент -->
|
||||
<main class="lg:col-span-8 w-full min-w-0">
|
||||
{
|
||||
posts.length > 0 ? (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12 w-full">
|
||||
{posts.map((post) => (
|
||||
<PostCard post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="bg-white/80 backdrop-blur-xl rounded-2xl p-12 text-center border border-white/50 shadow-lg w-full">
|
||||
<p class="text-gray-600">Пока нет публикаций</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Pagination />
|
||||
</main>
|
||||
|
||||
<!-- Сайдбар -->
|
||||
<aside class="lg:col-span-4 space-y-6 w-full">
|
||||
<SidebarSearch />
|
||||
<SidebarPopular posts={popularPosts} />
|
||||
<SidebarCategories categories={categories} />
|
||||
<TagCloud />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
302
frontend/src/components/blog/FeaturedPost.astro
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
---
|
||||
import { MONTHS } from '@lib/constants';
|
||||
|
||||
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || "http://localhost:8090";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
date: string;
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
image: string;
|
||||
link: string;
|
||||
isImportant?: boolean;
|
||||
}
|
||||
|
||||
interface PostRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
image?: string;
|
||||
tags?: string;
|
||||
isImportant?: boolean;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PocketBaseResponse {
|
||||
items: PostRecord[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subHeader?: string;
|
||||
title?: string;
|
||||
initialPosts?: Post[];
|
||||
}
|
||||
|
||||
const {
|
||||
subHeader = "Блог и новости",
|
||||
title = "Актуальное",
|
||||
} = Astro.props;
|
||||
|
||||
// Загружаем реальные статьи из PocketBase
|
||||
let posts: Post[] = [];
|
||||
|
||||
try {
|
||||
// Получаем все посты (без ограничения 3)
|
||||
const response = await fetch(`${POCKETBASE_URL}/api/collections/posts/records?perPage=20&sort=-created`);
|
||||
const data: PocketBaseResponse = await response.json();
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
// Разделяем на важные и обычные
|
||||
const importantPosts = data.items
|
||||
.filter(post => post.isImportant === true)
|
||||
.map(post => createPostData(post));
|
||||
|
||||
const regularPosts = data.items
|
||||
.filter(post => post.isImportant !== true)
|
||||
.map(post => createPostData(post));
|
||||
|
||||
// Берём максимум 3 поста: сначала важные, потом обычные
|
||||
posts = [...importantPosts, ...regularPosts].slice(0, 3);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[FeaturedPost] Error fetching posts from PocketBase:", error);
|
||||
}
|
||||
|
||||
// Функция создания данных поста
|
||||
function createPostData(post: PostRecord): Post {
|
||||
const date = new Date(post.created);
|
||||
const formattedDate = `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()} года`;
|
||||
|
||||
// Формируем URL изображения
|
||||
const imageUrl = post.image
|
||||
? `${POCKETBASE_URL}/api/files/posts/${post.id}/${post.image}`
|
||||
: "https://images.unsplash.com/photo-1589829085413-56de8ae18c73?q=80&w=2000&auto=format&fit=crop";
|
||||
|
||||
// Берём категорию из нового поля category
|
||||
const category = post.category && post.category.trim() !== "" ? post.category : "НОВОСТИ";
|
||||
|
||||
return {
|
||||
id: post.id,
|
||||
date: formattedDate,
|
||||
category,
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
image: imageUrl,
|
||||
link: `/blog/${post.slug}`,
|
||||
isImportant: post.isImportant === true
|
||||
};
|
||||
}
|
||||
|
||||
// Если постов нет, используем заглушки
|
||||
if (posts.length === 0) {
|
||||
posts = [{
|
||||
id: "1",
|
||||
date: `13 ${MONTHS[2]} 2026 года`,
|
||||
category: "НОВОСТИ",
|
||||
title: "Нет доступных публикаций",
|
||||
excerpt: "Пока нет статей для отображения",
|
||||
image: "https://images.unsplash.com/photo-1589829085413-56de8ae18c73?q=80&w=2000&auto=format&fit=crop",
|
||||
link: "/blog",
|
||||
isImportant: true
|
||||
}];
|
||||
}
|
||||
---
|
||||
|
||||
<div class="w-full py-12 md:py-16 overflow-hidden">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<!-- Заголовок -->
|
||||
<div class="mb-8 md:mb-12 text-center">
|
||||
<span class="inline-block px-4 py-1.5 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] font-bold text-xs uppercase tracking-widest mb-4">
|
||||
{subHeader}
|
||||
</span>
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-gradient-to-r from-transparent via-[var(--color-gold)] to-transparent mx-auto mt-4 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Обертка карусели -->
|
||||
<div class="relative group/slider overflow-hidden rounded-3xl shadow-2xl shadow-gray-900/10 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 transition-all duration-500 bg-white/80 backdrop-blur-xl border border-white/50">
|
||||
|
||||
<!-- Улучшенные кнопки управления (скрыты на мобильных) -->
|
||||
<button
|
||||
id="prevBtn"
|
||||
class="absolute left-4 md:left-6 top-1/2 -translate-y-1/2 z-20 w-10 h-10 md:w-14 md:h-14 rounded-full bg-white/95 backdrop-blur border border-gray-200 shadow-lg flex items-center justify-center text-gray-400 opacity-0 md:group-hover/slider:opacity-100 transition-all duration-300 hover:bg-[var(--color-gold)] hover:text-white hover:border-[var(--color-gold)] hover:scale-110 cursor-pointer pointer-events-none md:group-hover/slider:pointer-events-auto"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<svg class="w-5 h-5 md:w-6 md:h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="nextBtn"
|
||||
class="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 z-20 w-10 h-10 md:w-14 md:h-14 rounded-full bg-white/95 backdrop-blur border border-gray-200 shadow-lg flex items-center justify-center text-gray-400 opacity-0 md:group-hover/slider:opacity-100 transition-all duration-300 hover:bg-[var(--color-gold)] hover:text-white hover:border-[var(--color-gold)] hover:scale-110 cursor-pointer pointer-events-none md:group-hover/slider:pointer-events-auto"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<svg class="w-5 h-5 md:w-6 md:h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Трек слайдов -->
|
||||
<div class="overflow-hidden">
|
||||
<div id="track" class="flex transition-transform duration-700 ease-out h-full">
|
||||
{posts.map((post) => (
|
||||
<article class="slide-item w-full flex-shrink-0 flex flex-col md:flex-row min-h-[400px] md:min-h-[500px]">
|
||||
<!-- Изображение с улучшенным overlay -->
|
||||
<div class="relative w-full md:w-1/2 h-64 md:h-auto overflow-hidden group/image">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover/image:scale-110"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-black/10 to-transparent"></div>
|
||||
|
||||
{post.isImportant && (
|
||||
<div class="absolute top-4 left-4 md:top-6 md:left-6">
|
||||
<span class="bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white text-[10px] font-extrabold px-3 py-1.5 md:px-4 md:py-2 rounded-full uppercase tracking-wider shadow-lg shadow-[var(--color-gold)]/30 flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
|
||||
Важное
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Контент с glassmorphism -->
|
||||
<div class="w-full md:w-1/2 p-6 md:p-8 lg:p-12 flex flex-col justify-center bg-white/50 backdrop-blur-sm relative">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-3 text-xs font-bold uppercase tracking-widest mb-4 md:mb-6">
|
||||
<span class="px-2 md:px-3 py-1 bg-gray-100 rounded-full text-gray-600">{post.date}</span>
|
||||
<span class="w-1 h-1 bg-[var(--color-gold)] rounded-full hidden sm:inline-block"></span>
|
||||
<span class="text-[var(--color-blue-primary)] bg-[var(--color-blue-primary)]/10 px-2 md:px-3 py-1 rounded-full">{post.category}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl md:text-3xl lg:text-4xl font-bold text-gray-900 leading-tight mb-4 md:mb-6">
|
||||
<a href={post.link} class="hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{post.title}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<p class="text-sm md:text-base text-gray-700 leading-relaxed mb-6 md:mb-8 line-clamp-3 md:line-clamp-4">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto">
|
||||
<a
|
||||
href={post.link}
|
||||
class="inline-flex items-center gap-2 md:gap-3 px-5 md:px-8 py-3 md:py-4 bg-gray-900 text-white rounded-xl font-semibold hover:bg-[var(--color-gold)] transition-all duration-300 hover:shadow-lg hover:shadow-[var(--color-gold)]/30 hover:-translate-y-0.5 group/link text-sm md:text-base"
|
||||
>
|
||||
<span>Читать статью</span>
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5 transform group-hover/link:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Точки (Индикаторы) с улучшенным стилем -->
|
||||
<div class="absolute bottom-4 md:bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2 md:space-x-3 z-20 bg-white/80 backdrop-blur px-3 py-1.5 md:px-4 md:py-2 rounded-full border border-gray-100 shadow-sm">
|
||||
{posts.map((_, index) => (
|
||||
<button
|
||||
class="carousel-dot w-2 h-2 rounded-full bg-gray-300 hover:bg-gray-400 transition-all duration-300 cursor-pointer"
|
||||
data-index={index}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initInfiniteCarousel() {
|
||||
const track = document.getElementById('track');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
const dots = document.querySelectorAll('.carousel-dot');
|
||||
|
||||
if (!track || !prevBtn || !nextBtn) return;
|
||||
|
||||
const slides = Array.from(track.children) as HTMLElement[];
|
||||
const totalSlides = slides.length;
|
||||
|
||||
const firstClone = slides[0].cloneNode(true) as HTMLElement;
|
||||
const lastClone = slides[totalSlides - 1].cloneNode(true) as HTMLElement;
|
||||
|
||||
track.appendChild(firstClone);
|
||||
track.insertBefore(lastClone, slides[0]);
|
||||
|
||||
let currentIndex = 1;
|
||||
let isTransitioning = false;
|
||||
|
||||
track.style.transform = `translateX(-100%)`;
|
||||
|
||||
const updateDots = (index: number) => {
|
||||
let dotIndex = index - 1;
|
||||
if (dotIndex < 0) dotIndex = totalSlides - 1;
|
||||
if (dotIndex >= totalSlides) dotIndex = 0;
|
||||
|
||||
dots.forEach((dot, i) => {
|
||||
if (i === dotIndex) {
|
||||
dot.classList.remove('bg-gray-300', 'w-2');
|
||||
dot.classList.add('bg-[var(--color-blue-primary)]', 'w-4', 'md:w-8');
|
||||
} else {
|
||||
dot.classList.add('bg-gray-300', 'w-2');
|
||||
dot.classList.remove('bg-[var(--color-blue-primary)]', 'w-4', 'md:w-8');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const moveSlide = (index: number) => {
|
||||
if (isTransitioning) return;
|
||||
isTransitioning = true;
|
||||
currentIndex = index;
|
||||
|
||||
track.style.transition = 'transform 0.7s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
track.style.transform = `translateX(-${currentIndex * 100}%)`;
|
||||
|
||||
updateDots(currentIndex);
|
||||
};
|
||||
|
||||
track.addEventListener('transitionend', () => {
|
||||
isTransitioning = false;
|
||||
|
||||
if (currentIndex === 0) {
|
||||
track.style.transition = 'none';
|
||||
currentIndex = totalSlides;
|
||||
track.style.transform = `translateX(-${currentIndex * 100}%)`;
|
||||
}
|
||||
|
||||
if (currentIndex === totalSlides + 1) {
|
||||
track.style.transition = 'none';
|
||||
currentIndex = 1;
|
||||
track.style.transform = `translateX(-${currentIndex * 100}%)`;
|
||||
}
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => moveSlide(currentIndex + 1));
|
||||
prevBtn.addEventListener('click', () => moveSlide(currentIndex - 1));
|
||||
|
||||
dots.forEach((dot) => {
|
||||
dot.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const index = parseInt(target.getAttribute('data-index') || '0');
|
||||
moveSlide(index + 1);
|
||||
});
|
||||
});
|
||||
|
||||
updateDots(currentIndex);
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', initInfiniteCarousel);
|
||||
document.addEventListener('DOMContentLoaded', initInfiniteCarousel);
|
||||
</script>
|
||||
66
frontend/src/components/blog/PostCard.astro
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
interface Props {
|
||||
post: {
|
||||
image: string;
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="group relative bg-white/80 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/50 shadow-lg hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 transition-all duration-500 hover:-translate-y-1 flex flex-col h-full">
|
||||
<!-- Декоративный градиент при hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)]/5 via-transparent to-[var(--color-gold)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
|
||||
|
||||
<!-- Изображение -->
|
||||
<div class="relative h-56 overflow-hidden">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- Категория на изображении -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<span class="px-3 py-1 bg-white/95 backdrop-blur text-[var(--color-blue-primary)] text-[10px] font-bold uppercase tracking-wider rounded-full shadow-sm">
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="p-6 flex flex-col flex-grow relative z-10">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 font-medium mb-3">
|
||||
<svg class="w-4 h-4 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span>{post.date}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3 leading-tight group-hover:text-[var(--color-blue-primary)] transition-colors duration-300">
|
||||
<a href={post.slug} class="hover:text-[var(--color-blue-primary)]">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-700 text-sm mb-6 flex-grow line-clamp-3 leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<!-- Футер -->
|
||||
<div class="mt-auto pt-4 border-t border-gray-100 flex justify-between items-center">
|
||||
<a href={post.slug} class="inline-flex items-center text-sm font-bold text-[var(--color-blue-primary)] hover:text-[var(--color-gold)] transition-colors group/link">
|
||||
Читать далее
|
||||
<svg class="w-4 h-4 ml-2 transform group-hover/link:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
321
frontend/src/components/blog/SearchBox.astro
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<!-- Компонент поиска по сайту с модальным окном -->
|
||||
<div
|
||||
class="bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50"
|
||||
>
|
||||
<h4 class="font-bold text-gray-900 mb-6 flex items-center gap-2 justify-center text-center md:justify-start">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<span class="text-center">Поиск</span>
|
||||
</h4>
|
||||
|
||||
<!-- Кнопка для открытия модального окна -->
|
||||
<button
|
||||
id="openSearchModal"
|
||||
class="w-full px-5 py-3.5 text-sm bg-gray-50 hover:bg-gray-100 border border-gray-100 rounded-xl text-left text-gray-500 placeholder:text-gray-400 transition-all duration-300 flex items-center gap-3 cursor-pointer group"
|
||||
aria-label="Открыть поиск"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-[var(--color-blue-primary)] transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<span>Поиск статей...</span>
|
||||
<kbd
|
||||
class="ml-auto hidden sm:inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-gray-400 bg-gray-100 rounded-md"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
class="text-[10px] font-semibold text-gray-500 uppercase tracking-wider"
|
||||
>Популярные запросы:</span
|
||||
>
|
||||
<a
|
||||
href="/blog/category/ugolovnoe-pravo"
|
||||
class="text-[10px] font-medium text-[var(--color-blue-primary)] hover:text-[var(--color-gold)] transition-colors bg-gray-100 hover:bg-gray-200 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
Уголовное право
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/grazhdanskoe-pravo"
|
||||
class="text-[10px] font-medium text-[var(--color-blue-primary)] hover:text-[var(--color-gold)] transition-colors bg-gray-100 hover:bg-gray-200 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
Гражданское право
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/novosti"
|
||||
class="text-[10px] font-medium text-[var(--color-blue-primary)] hover:text-[var(--color-gold)] transition-colors bg-gray-100 hover:bg-gray-200 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
Новости
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно поиска -->
|
||||
<div
|
||||
id="searchModal"
|
||||
class="fixed inset-0 z-50 hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="searchModalTitle"
|
||||
>
|
||||
<!-- Затемнение фона (клик по нему закрывает модалку) -->
|
||||
<div
|
||||
id="searchModalBackdrop"
|
||||
class="absolute inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity opacity-0 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно (клик по контенту НЕ закрывает модалку) -->
|
||||
<div
|
||||
id="searchModalContainer"
|
||||
class="relative min-h-screen flex items-center justify-center p-4 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
id="searchModalPanel"
|
||||
class="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl transform transition-all scale-95 opacity-0 pointer-events-auto"
|
||||
>
|
||||
<!-- Заголовок -->
|
||||
<div
|
||||
class="flex items-center justify-between p-6 border-b border-gray-100"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="w-6 h-6 text-[var(--color-gold)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<h3 id="searchModalTitle" class="text-xl font-bold text-gray-900">
|
||||
Поиск по сайту
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
id="closeSearchModal"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-gray-100 hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-all duration-300 hover:cursor-pointer"
|
||||
aria-label="Закрыть поиск"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма поиска -->
|
||||
<form action="/blog/search" method="GET" class="p-6">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="searchInput"
|
||||
placeholder="Введите запрос для поиска статей..."
|
||||
class="w-full px-6 py-4 pr-14 text-base bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--color-blue-primary)] focus:border-transparent transition-all duration-300 placeholder:text-gray-400 text-gray-900"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 w-11 h-11 bg-[var(--color-blue-primary)] hover:bg-[var(--color-gold)] text-white rounded-lg flex items-center justify-center transition-all duration-300 hover:shadow-lg hover:shadow-[var(--color-gold)]/30 cursor-pointer"
|
||||
aria-label="Найти"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Подсказки -->
|
||||
<div class="mt-4 flex items-center gap-2 text-xs text-gray-500">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Поиск осуществляется по заголовкам и содержанию статей</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Популярные запросы в модальном окне -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-gold)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700"
|
||||
>Популярные запросы:</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/blog/category/ugolovnoe-pravo"
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-[var(--color-blue-primary)] text-gray-700 hover:text-white rounded-lg transition-all duration-300"
|
||||
>
|
||||
Уголовное право
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/grazhdanskoe-pravo"
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-[var(--color-blue-primary)] text-gray-700 hover:text-white rounded-lg transition-all duration-300"
|
||||
>
|
||||
Гражданское право
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/novosti"
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-[var(--color-blue-primary)] text-gray-700 hover:text-white rounded-lg transition-all duration-300"
|
||||
>
|
||||
Новости
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/biznes"
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-[var(--color-blue-primary)] text-gray-700 hover:text-white rounded-lg transition-all duration-300"
|
||||
>
|
||||
Бизнес
|
||||
</a>
|
||||
<a
|
||||
href="/blog/category/semejnoe-pravo"
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-[var(--color-blue-primary)] text-gray-700 hover:text-white rounded-lg transition-all duration-300"
|
||||
>
|
||||
Семейное право
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Элементы модального окна
|
||||
const openBtn = document.getElementById("openSearchModal");
|
||||
const closeBtn = document.getElementById("closeSearchModal");
|
||||
const modal = document.getElementById("searchModal");
|
||||
const backdrop = document.getElementById("searchModalBackdrop");
|
||||
const panel = document.getElementById("searchModalPanel");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
|
||||
// Открытие модального окна
|
||||
function openModal() {
|
||||
modal?.classList.remove("hidden");
|
||||
// Небольшая задержка для анимации
|
||||
setTimeout(() => {
|
||||
backdrop?.classList.remove("opacity-0");
|
||||
backdrop?.classList.add("opacity-100");
|
||||
panel?.classList.remove("scale-95", "opacity-0");
|
||||
panel?.classList.add("scale-100", "opacity-100");
|
||||
searchInput?.focus();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Закрытие модального окна
|
||||
function closeModal() {
|
||||
backdrop?.classList.add("opacity-0");
|
||||
backdrop?.classList.remove("opacity-100");
|
||||
panel?.classList.add("scale-95", "opacity-0");
|
||||
panel?.classList.remove("scale-100", "opacity-100");
|
||||
setTimeout(() => {
|
||||
modal?.classList.add("hidden");
|
||||
searchInput?.blur();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Обработчики событий
|
||||
openBtn?.addEventListener("click", openModal);
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
|
||||
// Закрытие по клику на backdrop (затемненный фон)
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener("click", closeModal);
|
||||
}
|
||||
|
||||
// Закрытие по Escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal && !modal.classList.contains("hidden")) {
|
||||
closeModal();
|
||||
}
|
||||
// Открытие по Ctrl+K или Cmd+K
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (modal?.classList.contains("hidden")) {
|
||||
openModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Предотвращаем закрытие при клике на само модальное окно
|
||||
if (panel) {
|
||||
panel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
41
frontend/src/components/blog/SidebarCategories.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
interface Props {
|
||||
categories: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { categories } = Astro.props;
|
||||
|
||||
// Функция для URL-safe кодирования категории
|
||||
function encodeCategory(name: string): string {
|
||||
return encodeURIComponent(name.toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
---
|
||||
|
||||
<div class="bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50">
|
||||
<h4 class="font-bold text-gray-900 mb-6 flex items-center gap-2 justify-center text-center md:justify-start">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
|
||||
</svg>
|
||||
<span class="text-center">Категории</span>
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{categories.length > 0 ? (
|
||||
categories.map(cat => (
|
||||
<a
|
||||
href={`/blog/category/${encodeCategory(cat.name)}`}
|
||||
class="group inline-flex items-center px-4 py-2.5 bg-gray-50 hover:bg-[var(--color-blue-primary)] border border-gray-100 hover:border-[var(--color-blue-primary)] rounded-xl text-sm font-semibold text-gray-700 hover:text-white transition-all duration-300 hover:shadow-lg hover:shadow-[var(--color-blue-primary)]/20 hover:-translate-y-0.5"
|
||||
>
|
||||
{cat.name}
|
||||
<span class="ml-2 px-2 py-0.5 bg-white/50 group-hover:bg-white/20 rounded-full text-xs transition-colors">
|
||||
{cat.count}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<p class="text-sm text-gray-500">Категории загружаются...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
46
frontend/src/components/blog/SidebarNewsletter.astro
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<div class="relative overflow-hidden rounded-2xl shadow-2xl shadow-[var(--color-blue-primary)]/20 group">
|
||||
<!-- Фон с градиентом -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)] to-blue-700"></div>
|
||||
|
||||
<!-- Декоративные круги -->
|
||||
<div class="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-10 rounded-full blur-3xl group-hover:opacity-20 transition-opacity duration-500"></div>
|
||||
<div class="absolute -bottom-10 -left-10 w-32 h-32 bg-[var(--color-gold)] opacity-20 rounded-full blur-2xl group-hover:opacity-30 transition-opacity duration-500"></div>
|
||||
|
||||
<!-- Pattern -->
|
||||
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(circle at 2px 2px, white 1px, transparent 0); background-size: 24px 24px;"></div>
|
||||
|
||||
<div class="relative z-10 p-8 text-center">
|
||||
<div class="w-14 h-14 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/20 shadow-inner">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-white mb-2">Будьте в курсе</h3>
|
||||
<p class="text-blue-100 text-sm mb-6 leading-relaxed">
|
||||
Получайте свежие юридические советы и новости законодательства прямо на почту
|
||||
</p>
|
||||
|
||||
<form class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Ваш email адрес"
|
||||
class="w-full bg-white/10 backdrop-blur border border-white/20 text-white placeholder-blue-200/70 rounded-xl py-3.5 px-4 text-sm focus:outline-none focus:bg-white/20 focus:border-white/40 transition-all"
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full bg-white text-[var(--color-blue-primary)] font-bold py-3.5 rounded-xl hover:bg-[var(--color-gold)] hover:text-white transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
|
||||
Подписаться
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-blue-200/60 text-[10px] mt-4">
|
||||
Не отправляем спам. Только полезная информация.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
37
frontend/src/components/blog/SidebarPopular.astro
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
interface Props {
|
||||
posts: Array<{
|
||||
image: string;
|
||||
title: string;
|
||||
views: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { posts } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50">
|
||||
<h4 class="font-bold text-gray-900 mb-6 flex items-center gap-2 justify-center text-center md:justify-start">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span class="text-center">Популярные статьи</span>
|
||||
</h4>
|
||||
<div class="space-y-5">
|
||||
{posts.map(post => (
|
||||
<div class="flex gap-4 group cursor-pointer p-2 -mx-2 rounded-xl hover:bg-gray-50 transition-colors duration-300">
|
||||
<div class="w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<img src={post.image} alt="" class="w-full h-full object-cover group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="text-sm font-bold text-gray-900 leading-snug group-hover:text-[var(--color-blue-primary)] transition-colors mb-1">
|
||||
{post.title}
|
||||
</h5>
|
||||
<span class="text-[10px] font-bold text-gray-500 uppercase tracking-wide">
|
||||
{post.views}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
6
frontend/src/components/blog/SidebarSearch.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
// Обёртка для компонента поиска
|
||||
import SearchBox from './SearchBox.astro';
|
||||
---
|
||||
|
||||
<SearchBox />
|
||||
71
frontend/src/components/blog/TagCloud.astro
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
// Теги для блога (статические данные для примера)
|
||||
const tags = [
|
||||
{ name: "Законодательство", count: 15, size: "large" },
|
||||
{ name: "Судебная практика", count: 12, size: "medium" },
|
||||
{ name: "Бизнес", count: 8, size: "medium" },
|
||||
{ name: "Недвижимость", count: 6, size: "small" },
|
||||
{ name: "Семья", count: 4, size: "small" },
|
||||
{ name: "Налоги", count: 10, size: "large" },
|
||||
{ name: "Трудовое право", count: 7, size: "medium" },
|
||||
{ name: "Защита прав", count: 5, size: "small" },
|
||||
{ name: "Арбитраж", count: 9, size: "medium" },
|
||||
{ name: "Уголовное право", count: 11, size: "large" },
|
||||
{ name: "Административное", count: 3, size: "small" },
|
||||
{ name: "Договоры", count: 6, size: "small" },
|
||||
];
|
||||
|
||||
// Функция для получения размера тега
|
||||
function getTagClasses(size: string) {
|
||||
const baseClasses =
|
||||
"inline-flex items-center px-4 py-2 bg-[var(--color-blue-primary)]/10 border border-[var(--color-blue-primary)]/20 rounded-full font-medium transition-all duration-300 hover:bg-[var(--color-blue-primary)] hover:border-[var(--color-blue-primary)] hover:text-white hover:shadow-lg hover:shadow-[var(--color-blue-primary)]/30 hover:-translate-y-0.5 cursor-pointer";
|
||||
|
||||
switch (size) {
|
||||
case "large":
|
||||
return `${baseClasses} text-base text-[var(--color-blue-primary)]`;
|
||||
case "medium":
|
||||
return `${baseClasses} text-sm text-[var(--color-blue-primary)]`;
|
||||
default:
|
||||
return `${baseClasses} text-xs text-[var(--color-blue-primary)]`;
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class="bg-white/80 backdrop-blur-xl p-6 rounded-2xl shadow-lg border border-white/50"
|
||||
>
|
||||
<h4 class="font-bold text-gray-900 mb-2 flex items-center gap-2 justify-center text-center md:justify-start">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.148"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-center">Облако тегов</span>
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mb-4 text-center md:text-left">Найдите статьи по ключевому тегу</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{
|
||||
tags.map((tag) => (
|
||||
<a
|
||||
href={`/blog/search?q=${encodeURIComponent(tag.name)}`}
|
||||
class={getTagClasses(tag.size)}
|
||||
title={`${tag.count} статей`}
|
||||
>
|
||||
{tag.name}
|
||||
<span class="ml-1.5 px-1.5 py-0.5 bg-[var(--color-blue-primary)]/20 rounded-full text-[10px] font-bold">
|
||||
{tag.count}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
93
frontend/src/components/cases/CaseCard.astro
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
export interface Props {
|
||||
case: {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
result: 'won' | 'settled' | 'ongoing';
|
||||
amount?: string;
|
||||
date: string;
|
||||
description: string;
|
||||
tag: string;
|
||||
};
|
||||
}
|
||||
|
||||
const { case: caseData } = Astro.props;
|
||||
|
||||
const resultConfig = {
|
||||
won: {
|
||||
label: 'Дело выиграно',
|
||||
color: 'bg-green-500',
|
||||
bg: 'bg-green-50',
|
||||
text: 'text-green-700',
|
||||
stamp: 'ПОБЕДА'
|
||||
},
|
||||
settled: {
|
||||
label: 'Урегулировано',
|
||||
color: 'bg-[var(--color-gold)]',
|
||||
bg: 'bg-amber-50',
|
||||
text: 'text-amber-700',
|
||||
stamp: 'СОГЛАШЕНИЕ'
|
||||
},
|
||||
ongoing: {
|
||||
label: 'В процессе',
|
||||
color: 'bg-blue-500',
|
||||
bg: 'bg-blue-50',
|
||||
text: 'text-blue-700',
|
||||
stamp: 'В РАБОТЕ'
|
||||
}
|
||||
};
|
||||
|
||||
const config = resultConfig[caseData.result];
|
||||
---
|
||||
|
||||
<article class="group relative bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 transition-all duration-500 border border-gray-100">
|
||||
<!-- Верхняя плашка с категорией -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-100 flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">{caseData.category}</span>
|
||||
<span class="text-xs text-gray-400">{caseData.date}</span>
|
||||
</div>
|
||||
|
||||
<div class="p-6 relative">
|
||||
<!-- "Печать" результата (появляется при hover) -->
|
||||
<div class="absolute top-4 right-4 w-20 h-20 border-4 border-[var(--color-gold)] rounded-full flex items-center justify-center transform rotate-12 opacity-0 group-hover:opacity-100 transition-all duration-500 scale-50 group-hover:scale-100 pointer-events-none">
|
||||
<span class="text-[10px] font-black text-[var(--color-gold)] uppercase text-center leading-tight">{config.stamp}</span>
|
||||
</div>
|
||||
|
||||
<!-- Тег дела -->
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg ${config.bg} ${config.text} text-xs font-bold mb-4">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${config.color}"></span>
|
||||
{caseData.tag}
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3 line-clamp-2 group-hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{caseData.title}
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-gray-600 text-sm leading-relaxed mb-6 line-clamp-3">
|
||||
{caseData.description}
|
||||
</p>
|
||||
|
||||
<!-- Футер с суммой и ссылкой -->
|
||||
<div class="flex items-end justify-between pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
{caseData.amount && (
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Сумма дела</div>
|
||||
<div class="text-xl font-black text-gray-900">{caseData.amount}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button class="flex items-center gap-2 text-sm font-bold text-[var(--color-blue-primary)] hover:text-[var(--color-gold)] transition-colors group/btn">
|
||||
Подробнее
|
||||
<svg class="w-4 h-4 transform group-hover/btn:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Цветная полоска снизу -->
|
||||
<div class="h-1 w-full ${config.color} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left"></div>
|
||||
</article>
|
||||
544
frontend/src/components/cases/CasesGrid.astro
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
---
|
||||
import CaseCard from "./CaseCard.astro";
|
||||
|
||||
// Массив из 12 дел
|
||||
const allCases = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Возмещение ущерба при ДТП с участием грузового транспорта",
|
||||
category: "Административное",
|
||||
result: "won" as const,
|
||||
amount: "₽3.2 млн",
|
||||
date: "2024-01-15",
|
||||
description:
|
||||
"Взыскание ущерба с виновника ДТП и страховой компании после отказа в выплате по факту превышения лимита ОСАГО.",
|
||||
tag: "ДТП",
|
||||
complexity: "medium",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title:
|
||||
"Защита при обвинении по ст. 228 УК РФ (незаконный оборот наркотиков)",
|
||||
category: "Уголовное",
|
||||
result: "settled" as const,
|
||||
date: "2023-12-10",
|
||||
description:
|
||||
"Доказано нарушение правил хранения вещественных доказательств. Дело прекращено за отсутствием состава преступления.",
|
||||
tag: "Отказ в возбуждении",
|
||||
complexity: "high",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Арбитражный спор о взыскании задолженности по договору поставки",
|
||||
category: "Арбитраж",
|
||||
result: "won" as const,
|
||||
amount: "₽18.5 млн",
|
||||
date: "2024-03-22",
|
||||
description:
|
||||
"Взыскание основного долга, процентов и неустойки с контрагента, злоупотреблявшего правом на отсрочку платежа.",
|
||||
tag: "Взыскание долга",
|
||||
complexity: "high",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title:
|
||||
"Раздел совместно нажитого имущества супругов стоимостью более 50 млн",
|
||||
category: "Семейное",
|
||||
result: "settled" as const,
|
||||
amount: "Сохранено 70%",
|
||||
date: "2024-02-14",
|
||||
description:
|
||||
"Достигнуто мирное соглашение, позволившее клиенту сохранить бизнес и недвижимость без длительных судебных разбирательств.",
|
||||
tag: "Медиация",
|
||||
complexity: "medium",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Оспаривание кадастровой стоимости торгового центра",
|
||||
category: "Гражданское",
|
||||
result: "won" as const,
|
||||
amount: "₽8.9 млн налогов",
|
||||
date: "2023-11-30",
|
||||
description:
|
||||
"Снижение кадастровой стоимости на 40%, что привело к существенной экономии на налоге на имущество в последующие годы.",
|
||||
tag: "Налоги",
|
||||
complexity: "medium",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title:
|
||||
"Защита директора по делу о банкротстве с признаками преднамеренного",
|
||||
category: "Уголовное",
|
||||
result: "ongoing" as const,
|
||||
date: "2024-04-05",
|
||||
description:
|
||||
"Доказывание добросовестности руководителя при банкротстве предприятия, отсутствия ущерба кредиторам.",
|
||||
tag: "Субсидиарная ответственность",
|
||||
complexity: "high",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
title: "Взыскание неустойки с застройщика за просрочку сдачи квартиры",
|
||||
category: "Гражданское",
|
||||
result: "won" as const,
|
||||
amount: "₽2.8 млн",
|
||||
date: "2024-05-18",
|
||||
description:
|
||||
"Успешное взыскание неустойки по договору долевого участия с одновременным сохранением права собственности.",
|
||||
tag: "ДДУ",
|
||||
complexity: "low",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
title: "Защита бизнеса от рейдерского захвата через подложные документы",
|
||||
category: "Арбитраж",
|
||||
result: "won" as const,
|
||||
amount: "Сохранён бизнес",
|
||||
date: "2024-06-12",
|
||||
description:
|
||||
"Оспаривание сделок по передаче долей в уставном капитале, совершённых с использованием поддельных подписей.",
|
||||
tag: "Корпоративные споры",
|
||||
complexity: "high",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
title: "Прекращение уголовного дела о мошенничестве в сфере строительства",
|
||||
category: "Уголовное",
|
||||
result: "settled" as const,
|
||||
date: "2024-07-20",
|
||||
description:
|
||||
"Доказано отсутствие умысла на хищение денежных средств инвесторов при реализации проекта.",
|
||||
tag: "Прекращение дела",
|
||||
complexity: "high",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
title: "Восстановление на работе и компенсация морального вреда",
|
||||
category: "Трудовое",
|
||||
result: "won" as const,
|
||||
amount: "₽1.5 млн",
|
||||
date: "2024-08-03",
|
||||
description:
|
||||
"Успешное оспаривание незаконного увольнения с восстановлением на прежней должности и выплатой среднего заработка.",
|
||||
tag: "Увольнение",
|
||||
complexity: "low",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
title: "Защита интеллектуальной собственности — товарный знак",
|
||||
category: "Гражданское",
|
||||
result: "ongoing" as const,
|
||||
date: "2024-09-15",
|
||||
description:
|
||||
"Судебное пресечение незаконного использования зарегистрированного товарного знака конкурентом.",
|
||||
tag: "Авторское право",
|
||||
complexity: "medium",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
title: "Оспаривание решения налоговой о доначислении НДС",
|
||||
category: "Налоговое",
|
||||
result: "won" as const,
|
||||
amount: "₽24 млн",
|
||||
date: "2024-10-08",
|
||||
description:
|
||||
"Отмена акта налоговой проверки и решения о привлечении к ответственности по сделкам с контрагентами.",
|
||||
tag: "Налоговые споры",
|
||||
complexity: "high",
|
||||
},
|
||||
];
|
||||
|
||||
// Уникальные категории для фильтров
|
||||
const categories = ["Все", ...new Set(allCases.map((c) => c.category))];
|
||||
---
|
||||
|
||||
<section class="py-16 px-4 bg-gray-50" id="cases-grid">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<!-- Заголовок -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Последние дела</h2>
|
||||
<p class="text-gray-500 mt-2">
|
||||
Выберите категорию для фильтрации или сортировку
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры по категориями (табы) -->
|
||||
<div class="flex flex-wrap gap-2 mb-6" id="category-filters">
|
||||
{
|
||||
categories.map((category, index) => (
|
||||
<button
|
||||
class={`category-filter px-4 py-2 rounded-full text-sm font-medium transition-all border cursor-pointer hover:cursor-pointer ${index === 0 ? "bg-[var(--color-navy)] text-white border-[var(--color-navy)]" : "bg-white text-gray-600 border-gray-200 hover:border-[var(--color-gold)] hover:text-[var(--color-gold)]"}`}
|
||||
data-category={category}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Сортировка -->
|
||||
<div class="flex items-center gap-3 mb-10 pb-6 border-b border-gray-200">
|
||||
<span class="text-sm text-gray-500 font-medium">Сортировать по:</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="sort-btn active px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-gold)]/10 text-[var(--color-gold)] border border-[var(--color-gold)] cursor-pointer hover:cursor-pointer"
|
||||
data-sort="date-desc"
|
||||
>
|
||||
Дате ↓
|
||||
</button>
|
||||
<button
|
||||
class="sort-btn px-4 py-2 rounded-lg text-sm font-medium bg-white text-gray-600 border border-gray-200 hover:border-[var(--color-gold)] cursor-pointer hover:cursor-pointer"
|
||||
data-sort="amount-desc"
|
||||
>
|
||||
Сумме ↓
|
||||
</button>
|
||||
<button
|
||||
class="sort-btn px-4 py-2 rounded-lg text-sm font-medium bg-white text-gray-600 border border-gray-200 hover:border-[var(--color-gold)] cursor-pointer hover:cursor-pointer"
|
||||
data-sort="complexity-desc"
|
||||
>
|
||||
Сложности ↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
id="cases-container"
|
||||
>
|
||||
{
|
||||
allCases.map((caseItem) => (
|
||||
<div
|
||||
class="case-item"
|
||||
data-category={caseItem.category}
|
||||
data-date={caseItem.date}
|
||||
data-amount={caseItem.amount}
|
||||
data-complexity={caseItem.complexity}
|
||||
data-id={caseItem.id}
|
||||
>
|
||||
<CaseCard case={caseItem} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Сообщение "Нет дел" -->
|
||||
<div id="no-cases-msg" class="hidden text-center py-16">
|
||||
<div class="text-6xl mb-4">📁</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Дела не найдены</h3>
|
||||
<p class="text-gray-500">Выберите другую категорию</p>
|
||||
</div>
|
||||
|
||||
<!-- Пагинация/Загрузить еще -->
|
||||
<div class="mt-12 text-center" id="load-more-container">
|
||||
<button
|
||||
id="load-more-btn"
|
||||
class="inline-flex items-center gap-3 px-8 py-4 bg-white border-2 border-gray-200 rounded-xl font-bold text-gray-700 hover:border-[var(--color-gold)] hover:text-[var(--color-gold)] transition-all group cursor-pointer hover:cursor-pointer"
|
||||
>
|
||||
<span>Загрузить еще дела</span>
|
||||
<span id="remaining-count" class="text-sm text-gray-400 font-normal"
|
||||
>(осталось 6)</span
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 transform group-hover:translate-y-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<p id="all-loaded-msg" class="hidden text-gray-500 font-medium">
|
||||
Все дела загружены
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Данные всех дел
|
||||
const allCasesData = [
|
||||
{
|
||||
id: "1",
|
||||
category: "Административное",
|
||||
date: "2024-01-15",
|
||||
amount: "₽3.2 млн",
|
||||
complexity: "medium",
|
||||
amountValue: 3.2,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
category: "Уголовное",
|
||||
date: "2023-12-10",
|
||||
amount: null,
|
||||
complexity: "high",
|
||||
amountValue: 0,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
category: "Арбитраж",
|
||||
date: "2024-03-22",
|
||||
amount: "₽18.5 млн",
|
||||
complexity: "high",
|
||||
amountValue: 18.5,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
category: "Семейное",
|
||||
date: "2024-02-14",
|
||||
amount: "Сохранено 70%",
|
||||
complexity: "medium",
|
||||
amountValue: 35,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
category: "Гражданское",
|
||||
date: "2023-11-30",
|
||||
amount: "₽8.9 млн налогов",
|
||||
complexity: "medium",
|
||||
amountValue: 8.9,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
category: "Уголовное",
|
||||
date: "2024-04-05",
|
||||
amount: null,
|
||||
complexity: "high",
|
||||
amountValue: 0,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
category: "Гражданское",
|
||||
date: "2024-05-18",
|
||||
amount: "₽2.8 млн",
|
||||
complexity: "low",
|
||||
amountValue: 2.8,
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
category: "Арбитраж",
|
||||
date: "2024-06-12",
|
||||
amount: "Сохранён бизнес",
|
||||
complexity: "high",
|
||||
amountValue: 50,
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
category: "Уголовное",
|
||||
date: "2024-07-20",
|
||||
amount: null,
|
||||
complexity: "high",
|
||||
amountValue: 0,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
category: "Трудовое",
|
||||
date: "2024-08-03",
|
||||
amount: "₽1.5 млн",
|
||||
complexity: "low",
|
||||
amountValue: 1.5,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
category: "Гражданское",
|
||||
date: "2024-09-15",
|
||||
amount: null,
|
||||
complexity: "medium",
|
||||
amountValue: 0,
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
category: "Налоговое",
|
||||
date: "2024-10-08",
|
||||
amount: "₽24 млн",
|
||||
complexity: "high",
|
||||
amountValue: 24,
|
||||
},
|
||||
];
|
||||
|
||||
let currentCategory = "Все";
|
||||
let currentSort = "date-desc";
|
||||
let visibleCount = 6;
|
||||
const batchSize = 3;
|
||||
|
||||
const caseItems = document.querySelectorAll(".case-item");
|
||||
const categoryButtons = document.querySelectorAll(".category-filter");
|
||||
const sortButtons = document.querySelectorAll(".sort-btn");
|
||||
const loadMoreBtn = document.getElementById("load-more-btn");
|
||||
const remainingCount = document.getElementById("remaining-count");
|
||||
const allLoadedMsg = document.getElementById("all-loaded-msg");
|
||||
const noCasesMsg = document.getElementById("no-cases-msg");
|
||||
const loadMoreContainer = document.getElementById("load-more-container");
|
||||
|
||||
// Фильтрация по категории
|
||||
categoryButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
// Обновляем активную кнопку
|
||||
categoryButtons.forEach((b) => {
|
||||
b.classList.remove(
|
||||
"bg-[var(--color-navy)]",
|
||||
"text-white",
|
||||
"border-[var(--color-navy)]",
|
||||
);
|
||||
b.classList.add("bg-white", "text-gray-600", "border-gray-200");
|
||||
});
|
||||
btn.classList.remove("bg-white", "text-gray-600", "border-gray-200");
|
||||
btn.classList.add(
|
||||
"bg-[var(--color-navy)]",
|
||||
"text-white",
|
||||
"border-[var(--color-navy)]",
|
||||
);
|
||||
|
||||
currentCategory = btn.dataset.category || "Все";
|
||||
visibleCount = 6; // Сбрасываем при смене фильтра
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
});
|
||||
|
||||
// Сортировка
|
||||
sortButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
sortButtons.forEach((b) => {
|
||||
b.classList.remove(
|
||||
"bg-[var(--color-gold)]/10",
|
||||
"text-[var(--color-gold)]",
|
||||
"border-[var(--color-gold)]",
|
||||
);
|
||||
b.classList.add("bg-white", "text-gray-600", "border-gray-200");
|
||||
});
|
||||
btn.classList.remove("bg-white", "text-gray-600", "border-gray-200");
|
||||
btn.classList.add(
|
||||
"bg-[var(--color-gold)]/10",
|
||||
"text-[var(--color-gold)]",
|
||||
"border-[var(--color-gold)]",
|
||||
);
|
||||
|
||||
currentSort = btn.dataset.sort || "date-desc";
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
});
|
||||
|
||||
// Применить фильтры и сортировку
|
||||
function applyFiltersAndSort() {
|
||||
let filtered = Array.from(caseItems);
|
||||
|
||||
// Фильтр по категории
|
||||
if (currentCategory !== "Все") {
|
||||
filtered = filtered.filter(
|
||||
(item) => item.dataset.category === currentCategory,
|
||||
);
|
||||
}
|
||||
|
||||
// Сортировка
|
||||
filtered.sort((a, b) => {
|
||||
const aId = a.dataset.id;
|
||||
const bId = b.dataset.id;
|
||||
const aData = allCasesData.find((c) => c.id === aId);
|
||||
const bData = allCasesData.find((c) => c.id === bId);
|
||||
|
||||
if (!aData || !bData) return 0;
|
||||
|
||||
switch (currentSort) {
|
||||
case "date-desc":
|
||||
return (
|
||||
new Date(bData.date).getTime() - new Date(aData.date).getTime()
|
||||
);
|
||||
case "amount-desc":
|
||||
return bData.amountValue - aData.amountValue;
|
||||
case "complexity-desc":
|
||||
const complexityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return (
|
||||
complexityOrder[bData.complexity] -
|
||||
complexityOrder[aData.complexity]
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Скрываем все
|
||||
caseItems.forEach((item) => {
|
||||
item.classList.add("hidden");
|
||||
item.classList.remove("animate-fade-in");
|
||||
});
|
||||
|
||||
// Показываем отфильтрованные с учётом лимита
|
||||
const toShow = filtered.slice(0, visibleCount);
|
||||
toShow.forEach((item, index) => {
|
||||
item.classList.remove("hidden");
|
||||
// Добавляем анимацию только при первой загрузке или смене фильтра
|
||||
if (index < 6) {
|
||||
setTimeout(() => item.classList.add("animate-fade-in"), index * 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Проверяем, есть ли дела
|
||||
if (filtered.length === 0) {
|
||||
noCasesMsg?.classList.remove("hidden");
|
||||
loadMoreContainer?.classList.add("hidden");
|
||||
} else {
|
||||
noCasesMsg?.classList.add("hidden");
|
||||
loadMoreContainer?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Обновляем кнопку "Загрузить ещё"
|
||||
const remaining = filtered.length - visibleCount;
|
||||
if (remainingCount) {
|
||||
if (remaining > 0) {
|
||||
remainingCount.textContent = `(осталось ${remaining})`;
|
||||
loadMoreBtn?.classList.remove("hidden");
|
||||
allLoadedMsg?.classList.add("hidden");
|
||||
} else {
|
||||
remainingCount.textContent = "";
|
||||
loadMoreBtn?.classList.add("hidden");
|
||||
allLoadedMsg?.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузить ещё
|
||||
loadMoreBtn?.addEventListener("click", () => {
|
||||
visibleCount += batchSize;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
|
||||
// Инициализация
|
||||
applyFiltersAndSort();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.case-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.category-filter {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
frontend/src/components/cases/CasesHero.astro
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
---
|
||||
import { CURRENT_YEAR } from '@constants/constants.ts';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Судебная практика",
|
||||
subtitle = `Реальные дела с реальными результатами. Прозрачность и доказательная база данных решений в судах ХМАО-Югры за ${CURRENT_YEAR} год.`
|
||||
} = Astro.props;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Все дела', count: 124, icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ id: 'criminal', label: 'Уголовные', count: 45, icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
|
||||
{ id: 'civil', label: 'Гражданские', count: 38, icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
|
||||
{ id: 'arbitration', label: 'Арбитраж', count: 28, icon: 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||
{ id: 'admin', label: 'Административные', count: 13, icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-20 md:py-28 bg-gradient-to-b from-[var(--color-navy)] via-[#0f172a] to-[#0a0f1c] overflow-hidden">
|
||||
<!-- Декоративный фон с анимированными элементами -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-[var(--color-gold)] opacity-[0.03] rounded-full blur-[100px] animate-pulse"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-[var(--color-blue-primary)] opacity-[0.05] rounded-full blur-[80px]"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-radial from-white/[0.02] to-transparent rounded-full"></div>
|
||||
|
||||
<!-- Тонкая сетка -->
|
||||
<div class="absolute inset-0 opacity-[0.02]" style="background-image: linear-gradient(rgba(255,255,255,.15) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.15) 1px, transparent 1px); background-size: 80px 80px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-16 max-w-3xl mx-auto">
|
||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-white/10 to-white/5 backdrop-blur-md border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-widest mb-6 shadow-lg shadow-[var(--color-gold)]/5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
Доказательная база
|
||||
</span>
|
||||
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight">
|
||||
{title}
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-gold)] via-[var(--color-gold-hover)] to-[var(--color-gold)] mt-2 animate-gradient">в цифрах</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-400 text-lg md:text-xl leading-relaxed max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Статистика-категории в виде сетки карточек -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 md:gap-6 max-w-6xl mx-auto" id="case-filters">
|
||||
{categories.map((cat, index) => (
|
||||
<button
|
||||
data-filter={cat.id}
|
||||
class:list={[
|
||||
"group relative flex flex-col items-center justify-center p-6 md:p-8 rounded-2xl border transition-all duration-500 ease-out",
|
||||
"hover:scale-105 hover:-translate-y-1",
|
||||
index === 0
|
||||
? "bg-gradient-to-br from-[var(--color-gold)]/20 to-[var(--color-gold)]/5 border-[var(--color-gold)] shadow-lg shadow-[var(--color-gold)]/20"
|
||||
: "bg-white/[0.03] backdrop-blur-sm border-white/10 hover:bg-white/[0.06] hover:border-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/10"
|
||||
]}
|
||||
>
|
||||
<!-- Иконка категории -->
|
||||
<div class:list={[
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-all duration-300",
|
||||
index === 0
|
||||
? "bg-[var(--color-gold)] text-[var(--color-navy)]"
|
||||
: "bg-white/10 text-[var(--color-gold)] group-hover:bg-[var(--color-gold)] group-hover:text-[var(--color-navy)]"
|
||||
]}>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d={cat.icon}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Число -->
|
||||
<span class:list={[
|
||||
"text-3xl md:text-4xl font-bold mb-2 transition-colors",
|
||||
index === 0 ? "text-white" : "text-white group-hover:text-[var(--color-gold)]"
|
||||
]}>
|
||||
{cat.count}
|
||||
</span>
|
||||
|
||||
<!-- Название -->
|
||||
<span class="text-gray-400 text-sm font-medium uppercase tracking-wider text-center group-hover:text-gray-300 transition-colors">
|
||||
{cat.label}
|
||||
</span>
|
||||
|
||||
<!-- Индикатор активности -->
|
||||
<div class:list={[
|
||||
"absolute -bottom-1 left-1/2 -translate-x-1/2 w-8 h-1 rounded-full transition-all duration-300",
|
||||
index === 0 ? "bg-[var(--color-gold)]" : "bg-transparent group-hover:bg-[var(--color-gold)]/50"
|
||||
]}></div>
|
||||
|
||||
<!-- Светящийся эффект при наведении -->
|
||||
<div class="absolute inset-0 rounded-2xl bg-gradient-to-t from-[var(--color-gold)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@keyframes gradient {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.animate-gradient {
|
||||
background-size: 200% auto;
|
||||
animation: gradient 8s ease infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Фильтрация с анимацией
|
||||
const filters = document.querySelectorAll('#case-filters button');
|
||||
|
||||
filters.forEach(filter => {
|
||||
filter.addEventListener('click', () => {
|
||||
// Сброс активных состояний
|
||||
filters.forEach(f => {
|
||||
f.classList.remove('bg-gradient-to-br', 'from-[var(--color-gold)]/20', 'to-[var(--color-gold)]/5', 'border-[var(--color-gold)]', 'shadow-lg', 'shadow-[var(--color-gold)]/20');
|
||||
f.classList.add('bg-white/[0.03]', 'border-white/10');
|
||||
|
||||
// Сброс иконок
|
||||
const iconDiv = f.querySelector('div:first-child');
|
||||
iconDiv?.classList.remove('bg-[var(--color-gold)]', 'text-[var(--color-navy)]');
|
||||
iconDiv?.classList.add('bg-white/10', 'text-[var(--color-gold)]');
|
||||
|
||||
// Сброс чисел
|
||||
const countSpan = f.querySelector('span:nth-child(2)');
|
||||
countSpan?.classList.remove('text-white');
|
||||
countSpan?.classList.add('text-white', 'group-hover:text-[var(--color-gold)]');
|
||||
|
||||
// Сброс индикатора
|
||||
const indicator = f.querySelector('div:last-child');
|
||||
indicator?.classList.remove('bg-[var(--color-gold)]');
|
||||
indicator?.classList.add('bg-transparent', 'group-hover:bg-[var(--color-gold)]/50');
|
||||
});
|
||||
|
||||
// Установка активного состояния
|
||||
filter.classList.remove('bg-white/[0.03]', 'border-white/10');
|
||||
filter.classList.add('bg-gradient-to-br', 'from-[var(--color-gold)]/20', 'to-[var(--color-gold)]/5', 'border-[var(--color-gold)]', 'shadow-lg', 'shadow-[var(--color-gold)]/20');
|
||||
|
||||
const activeIcon = filter.querySelector('div:first-child');
|
||||
activeIcon?.classList.remove('bg-white/10', 'text-[var(--color-gold)]');
|
||||
activeIcon?.classList.add('bg-[var(--color-gold)]', 'text-[var(--color-navy)]');
|
||||
|
||||
const activeCount = filter.querySelector('span:nth-child(2)');
|
||||
activeCount?.classList.remove('group-hover:text-[var(--color-gold)]');
|
||||
activeCount?.classList.add('text-white');
|
||||
|
||||
const activeIndicator = filter.querySelector('div:last-child');
|
||||
activeIndicator?.classList.remove('bg-transparent', 'group-hover:bg-[var(--color-gold)]/50');
|
||||
activeIndicator?.classList.add('bg-[var(--color-gold)]');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
132
frontend/src/components/cases/FeaturedCase.astro
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
import { CURRENT_YEAR } from '@constants/constants.ts';
|
||||
|
||||
// FeaturedCase.astro — Гражданское дело о защите прав потребителя
|
||||
// Все данные инкапсулированы внутри компонента, пропсы удалены
|
||||
---
|
||||
|
||||
<section class="py-16 px-4">
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<div
|
||||
class="w-12 h-12 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-xl flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900">Дело месяца</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative bg-gradient-to-br from-[var(--color-navy)] to-[#1e293b] rounded-3xl overflow-hidden shadow-2xl"
|
||||
>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
<!-- Изображение/Визуал -->
|
||||
<div class="relative h-64 lg:h-auto overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1560518883-ce09059eeffa?q=80&w=2000"
|
||||
alt="Недвижимость и строительство"
|
||||
class="w-full h-full object-cover opacity-60 mix-blend-overlay"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-[var(--color-navy)] via-transparent to-transparent lg:bg-gradient-to-r lg:from-transparent lg:to-[var(--color-navy)]"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Год дела -->
|
||||
<div
|
||||
class="absolute top-6 left-6 bg-white/10 backdrop-blur border border-white/20 rounded-full px-4 py-2"
|
||||
>
|
||||
<span class="text-white font-bold">{CURRENT_YEAR}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="p-8 lg:p-12 flex flex-col justify-center">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-500/30 text-blue-400 text-xs font-bold uppercase tracking-wider w-fit mb-6"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-blue-400 animate-pulse"></span>
|
||||
Гражданское право • Защита прав потребителей
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-2xl lg:text-3xl font-bold text-white mb-4 leading-tight"
|
||||
>
|
||||
Взыскание неустойки с застройщика и компенсация морального вреда
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-300 leading-relaxed mb-8">
|
||||
Дольщики многоквартирного дома обратились с иском к застройщику,
|
||||
систематически нарушавшему сроки сдачи объекта. В ходе досудебной
|
||||
претензионной работы и судебного разбирательства доказаны факты
|
||||
грубого нарушения договорных обязательств. Достигнуто положительное
|
||||
решение для 47 семей с полным возмещением убытков.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<div
|
||||
class="bg-white/5 backdrop-blur border border-white/10 rounded-xl p-4"
|
||||
>
|
||||
<div class="text-xs text-gray-400 uppercase tracking-wider mb-1">
|
||||
Результат
|
||||
</div>
|
||||
<div class="text-green-400 font-bold text-lg">
|
||||
Полное удовлетворение иска
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white/5 backdrop-blur border border-white/10 rounded-xl p-4"
|
||||
>
|
||||
<div class="text-xs text-gray-400 uppercase tracking-wider mb-1">
|
||||
Взыскано
|
||||
</div>
|
||||
<div class="text-[var(--color-gold)] font-bold text-lg">
|
||||
₽18.4 млн + неустойка
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-3 text-white font-bold group hover:text-[var(--color-gold)] transition-colors"
|
||||
>
|
||||
<span>Изучить детали дела</span>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full border border-white/20 flex items-center justify-center group-hover:border-[var(--color-gold)] group-hover:bg-[var(--color-gold)] transition-all"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративные линии -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--color-gold)] via-[var(--color-gold-hover)] to-transparent"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
125
frontend/src/components/contacts/BotWidget.astro
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
export interface Props {
|
||||
botName?: string;
|
||||
telegramLink: string;
|
||||
description?: string;
|
||||
responseTime?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
botName = "Юридический ассистент",
|
||||
telegramLink,
|
||||
description = "Опишите ситуацию боту — он задаст уточняющие вопросы, соберет необходимые документы и моментально передаст информацию адвокату. Это сократит время приема и подготовит юриста к вашему вопросу.",
|
||||
responseTime = "Ответы поступят в Telegram в течение 5 минут"
|
||||
} = Astro.props;
|
||||
|
||||
const features = [
|
||||
"Анализ юридической ситуации по вашему описанию",
|
||||
"Подготовка пакета документов к визиту",
|
||||
"Запись на прием к конкретному специалисту"
|
||||
];
|
||||
---
|
||||
|
||||
<div class="sticky top-8">
|
||||
<div class="group relative bg-white/80 backdrop-blur-xl border border-white/20 rounded-3xl p-8 overflow-hidden shadow-2xl shadow-gray-900/5 hover:shadow-2xl hover:shadow-[var(--color-gold)]/10 transition-all duration-500">
|
||||
<!-- Улучшенные декоративные элементы с анимацией -->
|
||||
<div class="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-[var(--color-gold)] to-amber-300 opacity-20 rounded-full blur-3xl -mr-20 -mt-20 animate-pulse"></div>
|
||||
<div class="absolute bottom-0 left-0 w-32 h-32 bg-gradient-to-tr from-[var(--color-blue-primary)] to-blue-400 opacity-20 rounded-full blur-3xl -ml-16 -mb-16 animate-pulse delay-1000"></div>
|
||||
|
||||
<!-- Дополнительный декоративный слой -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-gold)]/5 via-transparent to-[var(--color-blue-primary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Улучшенная секция с иконкой бота -->
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-5 mb-8">
|
||||
<div class="relative">
|
||||
<!-- Фон с анимацией при наведении -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-2xl blur opacity-20 group-hover:opacity-40 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- Иконка бота (робот) вместо лампочки -->
|
||||
<div class="relative w-16 sm:w-18 h-16 sm:h-18 bg-gradient-to-br from-[var(--color-gold)] via-amber-400 to-[var(--color-gold-hover)] rounded-2xl flex items-center justify-center shadow-lg shadow-[var(--color-gold)]/30 transform group-hover:scale-105 group-hover:rotate-1 transition-all duration-300">
|
||||
<svg class="w-7 sm:w-9 h-7 sm:h-9 text-white drop-shadow-md" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
|
||||
<!-- Мини-иконка Telegram в углу -->
|
||||
<div class="absolute -bottom-1 -right-1 w-5 sm:w-6 h-5 sm:h-6 bg-[#0088cc] rounded-full flex items-center justify-center shadow-md border-2 border-white">
|
||||
<svg class="w-3 sm:w-3.5 h-3 sm:h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Пульсирующее кольцо вокруг иконки -->
|
||||
<div class="absolute inset-0 rounded-2xl border-2 border-[var(--color-gold)]/30 animate-ping opacity-0 group-hover:opacity-100"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-center sm:text-left mt-3 sm:mt-0">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 leading-tight mb-1">{botName}</h2>
|
||||
<div class="flex items-center justify-center sm:justify-start gap-2 bg-green-50 w-fit px-2.5 py-1 rounded-full border border-green-100 mx-auto sm:mx-0">
|
||||
<div class="relative flex h-2 w-2 items-center justify-center">
|
||||
<div class="animate-ping absolute h-full w-full rounded-full bg-green-500 opacity-75"></div>
|
||||
<div class="relative inline-flex h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-green-700 uppercase tracking-wide">Онлайн 24/7</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Улучшенный заголовок -->
|
||||
<div class="mb-6 text-center sm:text-left">
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2 flex items-center gap-2 justify-center sm:justify-start">
|
||||
<span class="w-1 h-6 bg-gradient-to-b from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full hidden md:block"></span>
|
||||
Предварительная консультация
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 font-medium text-center sm:text-left">Через Telegram-бота</p>
|
||||
</div>
|
||||
|
||||
<!-- Описание с улучшенным форматированием -->
|
||||
<div class="bg-gray-50/80 rounded-2xl p-4 mb-6 border border-gray-100">
|
||||
<p class="text-gray-600 leading-relaxed text-[15px]">
|
||||
{description.split('моментально передаст информацию адвокату').map((part, i) =>
|
||||
i === 0 ? part : <><span class="text-[var(--color-gold)] font-bold bg-gradient-to-r from-[var(--color-gold)]/10 to-transparent px-1 rounded">моментально передаст информацию адвокату</span>{part}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Улучшенный список возможностей с иконками -->
|
||||
<ul class="space-y-3.5 mb-8">
|
||||
{features.map((feature, index) => (
|
||||
<li class="flex items-start gap-3 text-sm text-gray-700 group/item">
|
||||
<div class="flex-shrink-0 w-5 h-5 rounded-full bg-gradient-to-br from-[var(--color-gold)]/20 to-[var(--color-gold)]/5 flex items-center justify-center mt-0.5 group-hover/item:from-[var(--color-gold)] group-hover/item:to-[var(--color-gold-hover)] transition-colors duration-300">
|
||||
<svg class="w-3 h-3 text-[var(--color-gold)] group-hover/item:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="leading-snug group-hover/item:text-gray-900 transition-colors duration-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<!-- Улучшенная кнопка с эффектами -->
|
||||
<div class="relative group/btn">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-xl blur opacity-25 group-hover/btn:opacity-50 transition-opacity duration-300"></div>
|
||||
<a href={telegramLink} target="_blank" rel="noopener noreferrer"
|
||||
class="relative w-full flex items-center justify-center gap-3 px-8 py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] hover:from-[var(--color-gold-hover)] hover:to-[var(--color-gold)] text-white font-bold rounded-xl transition-all duration-300 shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 hover:-translate-y-0.5 active:translate-y-0">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
<span class="text-sm sm:text-base">Начать диалог с ботом</span>
|
||||
<svg class="w-5 h-5 group-hover/btn:translate-x-1 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Улучшенный футер с временем ответа -->
|
||||
<div class="mt-5 flex items-center justify-center gap-2 text-xs text-gray-500 bg-gray-50 rounded-lg py-2 px-3 border border-gray-100">
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="font-medium">{responseTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
37
frontend/src/components/contacts/ContactCard.astro
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
icon: string;
|
||||
children: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { title, icon, className = "" } = Astro.props;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
phone: "M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z",
|
||||
email: "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
|
||||
location: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
clock: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`group relative bg-white/80 backdrop-blur-xl border border-white/50 rounded-2xl p-6 hover:border-[var(--color-gold)]/50 transition-all duration-500 hover:shadow-2xl hover:shadow-[var(--color-gold)]/10 hover:-translate-y-1 ${className}`}>
|
||||
<!-- Декоративный градиент при hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-gold)]/5 via-transparent to-[var(--color-blue-primary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-2xl"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<!-- Улучшенная иконка с градиентом -->
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-[var(--color-blue-primary)] to-blue-600 rounded-2xl flex items-center justify-center mb-5 shadow-lg shadow-blue-500/25 group-hover:shadow-xl group-hover:shadow-[var(--color-gold)]/20 group-hover:scale-105 transition-all duration-300">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={icons[icon] || icons.phone}></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3 group-hover:text-[var(--color-gold)] transition-colors duration-300">{title}</h3>
|
||||
|
||||
<div class="contents text-gray-900 w-full">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
87
frontend/src/components/contacts/ContactsGrid.astro
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
import ContactCard from './ContactCard.astro';
|
||||
import type { ContactConfig } from '@/types/contacts.ts';
|
||||
|
||||
export interface Props {
|
||||
contacts: ContactConfig;
|
||||
}
|
||||
|
||||
const { contacts } = Astro.props;
|
||||
const { phones, email, address, hours } = contacts;
|
||||
---
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 text-center relative">
|
||||
Прямые контакты
|
||||
<span class="absolute -bottom-2 left-0 w-full h-1 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500"></span>
|
||||
</h2>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Телефоны -->
|
||||
<ContactCard title="Телефон" icon="phone">
|
||||
<div class="space-y-3">
|
||||
{phones.map((phone) => (
|
||||
<div class="group/item">
|
||||
<a href={phone.href} class="text-lg sm:text-xl md:text-2xl font-bold text-gray-900 hover:text-[var(--color-gold)] transition-colors duration-300 block relative inline-flex items-center gap-2">
|
||||
<span class="relative">
|
||||
{phone.number}
|
||||
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-[var(--color-gold)] transition-all duration-300 group-hover/item:w-full"></span>
|
||||
</span>
|
||||
</a>
|
||||
<span class="text-xs sm:text-sm text-gray-500 font-medium mt-1 block">{phone.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContactCard>
|
||||
|
||||
<!-- Email -->
|
||||
<ContactCard title="Email" icon="email">
|
||||
<a href={`mailto:${email.address}`} class="text-base sm:text-lg md:text-xl font-bold text-gray-900 hover:text-[var(--color-gold)] transition-colors duration-300 break-all relative inline-block group/link">
|
||||
{email.address}
|
||||
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] transition-all duration-300 group-hover/link:w-full"></span>
|
||||
</a>
|
||||
<p class="text-xs sm:text-sm text-gray-500 mt-3 font-medium bg-gray-50/50 inline-block px-3 py-1 rounded-full">{email.label}</p>
|
||||
</ContactCard>
|
||||
|
||||
<!-- Адрес -->
|
||||
<ContactCard title="Офис" icon="location">
|
||||
<p class="text-lg sm:text-xl md:text-xl font-bold text-gray-900 mb-2">{address.short}</p>
|
||||
<p class="text-sm sm:text-base text-gray-600 mb-4 leading-relaxed">{address.full}</p>
|
||||
<a href={address.mapLink} target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 text-xs sm:text-sm md:text-sm font-bold text-[var(--color-gold)] hover:text-[var(--color-gold-hover)] transition-all duration-300 bg-[var(--color-gold)]/10 hover:bg-[var(--color-gold)]/20 px-3 sm:px-4 py-1.5 sm:py-2 rounded-xl group/link">
|
||||
<span>Открыть на картах</span>
|
||||
<svg class="w-4 h-4 transform group-hover/link:translate-x-1 group-hover/link:-translate-y-1 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</ContactCard>
|
||||
|
||||
<!-- Режим работы -->
|
||||
<ContactCard title="Режим работы" icon="clock">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center p-3 bg-gray-50/80 rounded-xl border border-gray-100 gap-2">
|
||||
<span class="text-sm sm:text-base text-gray-600 font-medium">Пн — Пт</span>
|
||||
<span class="text-sm sm:text-base md:text-lg text-gray-900 font-bold">{hours.weekday}</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center p-3 bg-gray-50/50 rounded-xl border border-gray-100/50 gap-2">
|
||||
<span class="text-sm sm:text-base text-gray-500">Сб — Вс</span>
|
||||
<span class="text-sm sm:text-base text-gray-500 font-semibold">{hours.weekend}</span>
|
||||
</div>
|
||||
{hours.note && (
|
||||
<div class="pt-3 mt-3 border-t border-gray-200">
|
||||
<span class="text-xs sm:text-sm font-medium text-[var(--color-gold)] bg-[var(--color-gold)]/10 px-2.5 sm:px-3 py-1 rounded-full inline-flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{hours.note}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContactCard>
|
||||
</div>
|
||||
40
frontend/src/components/contacts/ContactsHero.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
export interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
highlightText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Контакты",
|
||||
subtitle = "Нужна юридическая помощь? Свяжитесь напрямую или доверьте предварительный анализ нашему",
|
||||
highlightText = "AI-ассистенту"
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="text-center mb-16 sm:mb-20 lg:mb-28 relative">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[200px] bg-gradient-to-r from-[var(--color-gold)]/20 via-[var(--color-blue-primary)]/20 to-[var(--color-gold)]/20 blur-3xl -z-10 opacity-50"></div>
|
||||
|
||||
<!-- Заголовок в стиле страницы кейсов -->
|
||||
<div class="mb-12 max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-center gap-2 px-4 py-2 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider mb-6 mx-auto w-fit">
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rounded-full animate-pulse"></span>
|
||||
Наши координаты
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl md:text-6xl lg:text-8xl font-bold tracking-tight text-gray-900 mb-6 relative inline-block">
|
||||
{title}
|
||||
<span class="absolute -bottom-3 sm:-bottom-4 left-1/2 -translate-x-1/2 w-16 sm:w-24 h-1 bg-gradient-to-r from-transparent via-[var(--color-gold)] to-transparent rounded-full"></span>
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-600 text-base sm:text-lg md:text-xl lg:text-2xl leading-relaxed font-light max-w-2xs sm:max-w-3xl mx-auto">
|
||||
{subtitle}
|
||||
<span class="relative inline-block mx-1 sm:mx-2 mt-2 block sm:inline">
|
||||
<span class="relative z-10 text-[var(--color-gold)] font-bold bg-gradient-to-r from-[var(--color-gold)]/20 to-[var(--color-gold-hover)]/20 px-2 sm:px-3 py-0.5 sm:py-1 rounded-lg sm:rounded-xl border border-[var(--color-gold)]/20 text-sm sm:text-base">
|
||||
{highlightText}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
53
frontend/src/components/contacts/EmergencyCard.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
export interface Props {
|
||||
phone: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
phone,
|
||||
title = "Срочная помощь",
|
||||
description = "Задержание, обыск, ДТП? Звоните круглосуточно."
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="mt-6 relative group">
|
||||
<!-- Пульсирующий фон -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-red-500 to-red-600 rounded-2xl blur opacity-20 group-hover:opacity-30 animate-pulse"></div>
|
||||
|
||||
<div class="relative bg-gradient-to-br from-red-500/10 to-red-600/5 backdrop-blur-xl border border-red-500/20 rounded-2xl p-6 overflow-hidden hover:border-red-500/40 transition-all duration-500 hover:shadow-2xl hover:shadow-red-500/20">
|
||||
<!-- Декоративный круг -->
|
||||
<div class="absolute top-0 right-0 w-32 h-32 bg-red-500/10 rounded-full blur-2xl -mr-16 -mt-16 animate-pulse"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="relative">
|
||||
<!-- Пульсирующая иконка -->
|
||||
<div class="absolute inset-0 bg-red-500 rounded-xl blur animate-ping opacity-20"></div>
|
||||
<div class="relative w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center shadow-lg shadow-red-500/30">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<h4 class="text-red-500 font-bold text-base sm:text-lg mb-1 flex items-center justify-center sm:justify-start gap-2">
|
||||
{title}
|
||||
<span class="px-2 py-0.5 bg-red-500/20 text-red-600 text-xs rounded-full animate-pulse">24/7</span>
|
||||
</h4>
|
||||
<p class="text-xs sm:text-sm text-gray-600 mb-4 leading-relaxed">{description}</p>
|
||||
|
||||
<a href={`tel:${phone.replace(/\D/g, '')}`} class="group/link inline-flex items-center gap-2 sm:gap-3 text-xl sm:text-2xl font-bold text-red-500 hover:text-red-600 transition-all duration-300 relative justify-center sm:justify-start">
|
||||
<span class="relative">
|
||||
{phone}
|
||||
<span class="absolute bottom-0 left-0 w-full h-0.5 bg-red-500 transform scale-x-0 group-hover/link:scale-x-100 transition-transform duration-300 origin-left"></span>
|
||||
</span>
|
||||
<svg class="w-5 sm:w-6 h-5 sm:h-6 transform group-hover/link:translate-x-1 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
28
frontend/src/components/contacts/MessengerButtons.astro
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
export interface Props {
|
||||
telegramLink: string;
|
||||
whatsappLink?: string;
|
||||
}
|
||||
|
||||
const { telegramLink, whatsappLink = "https://wa.me/79000000000" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 pt-6 w-full">
|
||||
<a href={telegramLink} target="_blank" rel="noopener noreferrer"
|
||||
class="group relative flex-1 min-w-[120px] sm:min-w-[140px] flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-3 sm:py-4 bg-[#0088cc]/5 backdrop-blur-sm border border-[#0088cc]/20 hover:border-[#0088cc]/50 rounded-xl sm:rounded-2xl text-[#0088cc] hover:bg-[#0088cc]/10 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg hover:shadow-[#0088cc]/20">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-[#0088cc]/0 via-[#0088cc]/5 to-[#0088cc]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-xl sm:rounded-2xl"></div>
|
||||
<svg class="w-5 sm:w-6 h-5 sm:h-6 transform group-hover:scale-110 transition-transform duration-300" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
<span class="font-bold text-sm sm:text-base">Telegram</span>
|
||||
</a>
|
||||
|
||||
<a href={whatsappLink} target="_blank" rel="noopener noreferrer"
|
||||
class="group relative flex-1 min-w-[120px] sm:min-w-[140px] flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-3 sm:py-4 bg-[#25D366]/5 backdrop-blur-sm border border-[#25D366]/20 hover:border-[#25D366]/50 rounded-xl sm:rounded-2xl text-[#25D366] hover:bg-[#25D366]/10 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg hover:shadow-[#25D366]/20">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-[#25D366]/0 via-[#25D366]/5 to-[#25D366]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-xl sm:rounded-2xl"></div>
|
||||
<svg class="w-5 sm:w-6 h-5 sm:h-6 transform group-hover:scale-110 transition-transform duration-300" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||
</svg>
|
||||
<span class="font-bold text-sm sm:text-base">WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
151
frontend/src/components/faq/FaqAccordion.astro
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
interface Props {
|
||||
question: string;
|
||||
answer: string;
|
||||
isOpen?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const { question, answer, isOpen = false, index = 1 } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class="faq-item group relative bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-2xl overflow-hidden transition-all duration-500 hover:border-[var(--color-gold)]/30 hover:shadow-lg hover:shadow-[var(--color-gold)]/5"
|
||||
class:list={[
|
||||
{
|
||||
active: isOpen,
|
||||
"border-[var(--color-gold)]/40 shadow-lg shadow-[var(--color-gold)]/10":
|
||||
isOpen,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<!-- Номер вопроса (декоративный) -->
|
||||
<div
|
||||
class="absolute top-4 right-4 text-6xl font-black text-[var(--color-gold)] opacity-5 pointer-events-none select-none"
|
||||
>
|
||||
{String(index).padStart(2, "0")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="faq-question w-full px-6 py-5 text-left flex items-center justify-between focus:outline-none transition-all duration-300 relative z-10 hover:cursor-pointer"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div class="flex items-center gap-4 pr-8">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 flex items-center justify-center flex-shrink-0 transition-all duration-300 group-hover:bg-[var(--color-gold)]/20 group-[.active]:bg-[var(--color-gold)] group-[.active]:text-[var(--color-white)]"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] group-[.active]:text-[var(--color-white)] transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="font-bold text-[var(--color-white)] text-lg group-hover:text-[var(--color-gold)] transition-colors duration-300 leading-tight"
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 flex items-center justify-center flex-shrink-0 ml-4 transition-all duration-300 group-hover:border-[var(--color-gold)]/30 group-[.active]:bg-[var(--color-gold)] group-[.active]:border-[var(--color-gold)]"
|
||||
>
|
||||
<svg
|
||||
class="faq-icon w-4 h-4 text-[var(--color-gray-400)] group-hover:text-[var(--color-gold)] transform transition-transform duration-300 group-[.active]:text-[var(--color-white)] group-[.active]:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="faq-answer overflow-hidden transition-all duration-500 ease-out"
|
||||
style={`max-height: ${isOpen ? "500px" : "0"}; opacity: ${isOpen ? "1" : "0"}`}
|
||||
>
|
||||
<div class="px-6 pb-6 pt-2">
|
||||
<div
|
||||
class="pl-14 text-[var(--color-gray-400)] leading-relaxed border-l-2 border-[var(--color-gold)]/30"
|
||||
>
|
||||
{answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initFaqAccordion() {
|
||||
const faqItems = document.querySelectorAll(".faq-item");
|
||||
|
||||
faqItems.forEach((item) => {
|
||||
const questionBtn = item.querySelector(".faq-question");
|
||||
const answerDiv = item.querySelector<HTMLElement>(".faq-answer");
|
||||
const icon = item.querySelector(".faq-icon");
|
||||
|
||||
if (questionBtn && answerDiv && icon) {
|
||||
// Устанавливаем начальное состояние
|
||||
if (item.classList.contains("active")) {
|
||||
setTimeout(() => {
|
||||
answerDiv.style.maxHeight = answerDiv.scrollHeight + "px";
|
||||
answerDiv.style.opacity = "1";
|
||||
icon.classList.add("rotate-180");
|
||||
}, 10);
|
||||
} else {
|
||||
answerDiv.style.maxHeight = "0";
|
||||
answerDiv.style.opacity = "0";
|
||||
}
|
||||
|
||||
questionBtn.addEventListener("click", () => {
|
||||
const isOpen = item.classList.contains("active");
|
||||
|
||||
// Закрываем все другие элементы
|
||||
faqItems.forEach((otherItem) => {
|
||||
if (otherItem !== item && otherItem.classList.contains("active")) {
|
||||
otherItem.classList.remove("active");
|
||||
const otherAnswer =
|
||||
otherItem.querySelector<HTMLElement>(".faq-answer");
|
||||
const otherIcon = otherItem.querySelector(".faq-icon");
|
||||
if (otherAnswer) {
|
||||
otherAnswer.style.maxHeight = "0px";
|
||||
otherAnswer.style.opacity = "0";
|
||||
}
|
||||
if (otherIcon) {
|
||||
otherIcon.classList.remove("rotate-180");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Переключаем текущий элемент
|
||||
if (isOpen) {
|
||||
item.classList.remove("active");
|
||||
answerDiv.style.maxHeight = "0px";
|
||||
answerDiv.style.opacity = "0";
|
||||
icon.classList.remove("rotate-180");
|
||||
} else {
|
||||
item.classList.add("active");
|
||||
answerDiv.style.maxHeight = answerDiv.scrollHeight + "px";
|
||||
answerDiv.style.opacity = "1";
|
||||
icon.classList.add("rotate-180");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", initFaqAccordion);
|
||||
initFaqAccordion();
|
||||
</script>
|
||||
155
frontend/src/components/home/Hero.astro
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
import { PRACTICE_START_YEAR } from "@constants/constants.ts";
|
||||
import { getYearDeclension } from "@utils/stats.utils.ts";
|
||||
|
||||
interface Props {
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
const { backgroundImage = "/images/hero/heroImg.avif" } = Astro.props;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearsOfPractice = currentYear - PRACTICE_START_YEAR;
|
||||
const yearDeclension = getYearDeclension(yearsOfPractice);
|
||||
---
|
||||
|
||||
<section
|
||||
class="group relative w-full min-h-screen flex items-center overflow-hidden bg-[var(--color-navy)]"
|
||||
>
|
||||
<!-- 1. ФОНОВОЕ ИЗОБРАЖЕНИЕ с эффектом проявления -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt="Адвокат Сургута"
|
||||
width="1920"
|
||||
height="1080"
|
||||
class="w-full h-full object-cover object-center
|
||||
opacity-40 mix-blend-overlay
|
||||
transition-all duration-1000 ease-out
|
||||
group-hover:opacity-100 group-hover:mix-blend-normal group-hover:scale-105"
|
||||
/>
|
||||
<!-- Градиенты (становятся светлее при наведении) -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-[var(--color-navy)] via-[var(--color-navy)]/60 to-[var(--color-navy)]/30 transition-all duration-1000 group-hover:via-[var(--color-navy)]/40 group-hover:to-[var(--color-navy)]/20"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-l from-[var(--color-navy)] via-[var(--color-navy)]/60 to-[var(--color-navy)]/30 transition-all duration-1000 group-hover:via-[var(--color-navy)]/40 group-hover:to-[var(--color-navy)]/20"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-[var(--color-navy)] via-transparent to-transparent opacity-60 transition-opacity duration-1000 group-hover:opacity-40"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративный элемент (без мерцания, статичный) -->
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 w-64 h-64 bg-[var(--color-blue-primary)] opacity-10 rounded-full blur-3xl pointer-events-none transition-opacity duration-1000 group-hover:opacity-20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 2. КОНТЕНТ -->
|
||||
<div class="relative z-10 w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl py-32">
|
||||
<div class="max-w-3xl text-center lg:text-left">
|
||||
<!-- Бейдж -->
|
||||
<div
|
||||
class="inline-flex items-center gap-3 px-4 py-2 bg-white/10 backdrop-blur-md border border-white/20 rounded-full mb-8"
|
||||
>
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rounded-full"></span>
|
||||
<span
|
||||
class="text-[var(--color-gold)] text-[10px] md:text-xs font-bold uppercase tracking-[0.2em]"
|
||||
>
|
||||
Юридическая защита
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Главный заголовок -->
|
||||
<h1
|
||||
class="text-white text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.1] mb-8 uppercase tracking-tight drop-shadow-2xl"
|
||||
>
|
||||
Лучший адвокат в <br />
|
||||
<span
|
||||
class="text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] italic font-serif"
|
||||
>Сургуте</span
|
||||
>
|
||||
</h1>
|
||||
|
||||
<!-- Описание -->
|
||||
<p
|
||||
class="text-gray-200 text-lg md:text-xl leading-relaxed mb-12 max-w-2xl border-l-0 md:border-l-4 border-[var(--color-gold)] pl-0 md:pl-6 transition-colors duration-700 group-hover:text-white text-center lg:text-left"
|
||||
>
|
||||
Высококвалифицированная правовая поддержка по самым сложным делам.
|
||||
{yearsOfPractice}
|
||||
{yearDeclension} безупречной репутации и сотни выигранных процессов в судах
|
||||
ХМАО - Югры.
|
||||
</p>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start items-center lg:items-start"
|
||||
>
|
||||
<a
|
||||
href="#contact"
|
||||
class="group/btn relative px-8 py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white font-bold rounded-xl overflow-hidden shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
<span class="relative z-10 flex items-center gap-2">
|
||||
Записаться на прием
|
||||
<svg
|
||||
class="w-5 h-5 transform group-hover/btn:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#services"
|
||||
class="group/btn px-8 py-4 bg-white/10 backdrop-blur-md border border-white/30 text-white font-bold rounded-xl hover:bg-white/20 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
Направления практики
|
||||
<svg
|
||||
class="w-5 h-5 transform group-hover/btn:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Индикатор прокрутки -->
|
||||
<div
|
||||
class="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 text-white/50 text-xs font-medium tracking-widest uppercase opacity-0 transition-opacity duration-1000 group-hover:opacity-100"
|
||||
>
|
||||
<span>Прокрутите</span>
|
||||
<svg
|
||||
class="w-4 h-4 animate-bounce"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
211
frontend/src/components/home/Reviews.astro
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
---
|
||||
const reviews = [
|
||||
{
|
||||
author: "Александр Волков",
|
||||
role: "ООО «СтройГрупп»",
|
||||
text: "Профессионал своего дела! Помог выиграть сложнейший арбитражный спор. Всегда на связи, всё четко и по делу.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
author: "Мария Смирнова",
|
||||
role: "Частное лицо",
|
||||
text: "Обращалась по вопросу раздела имущества. Процесс был тяжелым, но благодаря поддержке адвоката удалось достичь мирного соглашения.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
author: "Дмитрий Кузнецов",
|
||||
role: "Предприниматель",
|
||||
text: "Лучшая юридическая консультация, которую я когда-либо получал. Очень грамотно разобрали мою ситуацию по уголовному делу.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
author: "Елена Васечкина",
|
||||
role: "Гендиректор «Вектор»",
|
||||
text: "Сотрудничаем на постоянной основе уже 3 года. Полное юридическое сопровождение бизнеса на высшем уровне.",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="reviews" class="relative py-24 bg-[var(--color-navy)] overflow-hidden">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-[var(--color-blue-primary)] opacity-10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-white/10 text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider rounded-full mb-4">
|
||||
Репутация
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-white">
|
||||
Отзывы наших <span class="text-transparent bg-clip-text bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)]">доверителей</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Карусель -->
|
||||
<div class="relative group" id="reviews-carousel">
|
||||
<!-- Кнопки навигации -->
|
||||
<button id="prev-btn" class="absolute -left-4 lg:-left-12 top-1/2 -translate-y-1/2 z-20 w-12 h-12 bg-white/10 backdrop-blur-md border border-white/20 rounded-full flex items-center justify-center text-white hover:bg-[var(--color-gold)] hover:border-[var(--color-gold)] transition-all duration-300 opacity-0 group-hover:opacity-100 cursor-pointer">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="next-btn" class="absolute -right-4 lg:-right-12 top-1/2 -translate-y-1/2 z-20 w-12 h-12 bg-white/10 backdrop-blur-md border border-white/20 rounded-full flex items-center justify-center text-white hover:bg-[var(--color-gold)] hover:border-[var(--color-gold)] transition-all duration-300 opacity-0 group-hover:opacity-100 cursor-pointer">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Трек -->
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex gap-6" id="reviews-track">
|
||||
{reviews.map((review) => (
|
||||
<div class="flex-shrink-0 w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)]">
|
||||
<div class="h-full bg-white/10 backdrop-blur-md border border-white/10 rounded-2xl p-8 hover:bg-white/15 hover:border-[var(--color-gold)]/30 transition-all duration-300">
|
||||
<!-- Звезды -->
|
||||
<div class="flex gap-1 mb-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<svg class={`w-5 h-5 ${i < review.rating ? 'text-[var(--color-gold)]' : 'text-gray-600'}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<blockquote class="text-gray-300 text-lg leading-relaxed mb-8 italic">
|
||||
"{review.text}"
|
||||
</blockquote>
|
||||
|
||||
<div class="pt-6 border-t border-white/10">
|
||||
<h4 class="text-white font-bold text-lg mb-1">{review.author}</h4>
|
||||
<span class="text-[var(--color-gold)] text-sm font-medium">{review.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@keyframes scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.animate-scroll {
|
||||
animation: scroll 30s linear infinite;
|
||||
}
|
||||
.animate-scroll:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const initReviewsCarousel = () => {
|
||||
const track = document.getElementById('reviews-track');
|
||||
const prevBtn = document.getElementById('prev-btn');
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
|
||||
if (!track || !prevBtn || !nextBtn) return;
|
||||
|
||||
let currentIndex = 0;
|
||||
let autoScrollInterval: ReturnType<typeof setInterval>;
|
||||
const cards = track.querySelectorAll('.flex-shrink-0');
|
||||
const totalCards = cards.length;
|
||||
|
||||
// Определяем количество видимых карточек
|
||||
const getVisibleCards = () => {
|
||||
if (window.innerWidth >= 1024) return 3;
|
||||
if (window.innerWidth >= 768) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Обновление позиции трека
|
||||
const updateTrackPosition = () => {
|
||||
const cardWidth = cards[0]?.getBoundingClientRect().width || 0;
|
||||
const gap = 24; // 24px gap между карточками
|
||||
const visibleCards = getVisibleCards();
|
||||
const maxIndex = totalCards - visibleCards;
|
||||
|
||||
// Ограничиваем индекс
|
||||
if (currentIndex < 0) currentIndex = 0;
|
||||
if (currentIndex > maxIndex) currentIndex = maxIndex;
|
||||
|
||||
const offset = -(currentIndex * (cardWidth + gap));
|
||||
track.style.transform = `translateX(${offset}px)`;
|
||||
track.style.transition = 'transform 0.5s ease-in-out';
|
||||
};
|
||||
|
||||
// Автопрокрутка
|
||||
const startAutoScroll = () => {
|
||||
autoScrollInterval = setInterval(() => {
|
||||
const visibleCards = getVisibleCards();
|
||||
const maxIndex = totalCards - visibleCards;
|
||||
|
||||
if (currentIndex >= maxIndex) {
|
||||
currentIndex = 0;
|
||||
} else {
|
||||
currentIndex++;
|
||||
}
|
||||
updateTrackPosition();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const stopAutoScroll = () => {
|
||||
clearInterval(autoScrollInterval);
|
||||
};
|
||||
|
||||
// Обработчики кнопок
|
||||
prevBtn.addEventListener('click', () => {
|
||||
stopAutoScroll();
|
||||
const visibleCards = getVisibleCards();
|
||||
currentIndex = Math.max(0, currentIndex - visibleCards);
|
||||
updateTrackPosition();
|
||||
startAutoScroll();
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
stopAutoScroll();
|
||||
const visibleCards = getVisibleCards();
|
||||
const maxIndex = totalCards - visibleCards;
|
||||
currentIndex = Math.min(maxIndex, currentIndex + visibleCards);
|
||||
updateTrackPosition();
|
||||
startAutoScroll();
|
||||
});
|
||||
|
||||
// Дублируем карточки для бесконечной прокрутки (как в оригинале)
|
||||
const cloneCards = () => {
|
||||
cards.forEach(card => {
|
||||
const clone = card.cloneNode(true);
|
||||
clone.setAttribute('data-cloned', 'true');
|
||||
track.appendChild(clone);
|
||||
});
|
||||
};
|
||||
|
||||
// Инициализация
|
||||
cloneCards();
|
||||
updateTrackPosition();
|
||||
startAutoScroll();
|
||||
|
||||
// Пересчёт при изменении размера окна
|
||||
window.addEventListener('resize', () => {
|
||||
currentIndex = 0;
|
||||
updateTrackPosition();
|
||||
});
|
||||
};
|
||||
|
||||
// Запуск после загрузки страницы
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initReviewsCarousel);
|
||||
} else {
|
||||
initReviewsCarousel();
|
||||
}
|
||||
|
||||
// Для Astro View Transitions
|
||||
document.addEventListener('astro:page-load', initReviewsCarousel);
|
||||
</script>
|
||||
109
frontend/src/components/home/Services.astro
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
const services = [
|
||||
{
|
||||
title: "Уголовные дела",
|
||||
desc: "Эффективная защита на стадиях предварительного следствия и в суде по делам любой сложности.",
|
||||
href: "/services/criminal",
|
||||
icon: "scale"
|
||||
},
|
||||
{
|
||||
title: "Гражданские дела",
|
||||
desc: "Защита прав собственности, жилищные споры и взыскание задолженностей в судебном порядке.",
|
||||
href: "/services/civil",
|
||||
icon: "balance"
|
||||
},
|
||||
{
|
||||
title: "Семейные дела",
|
||||
desc: "Расторжение брака, раздел совместно нажитого имущества и определение места жительства детей.",
|
||||
href: "/services/family",
|
||||
icon: "users"
|
||||
},
|
||||
{
|
||||
title: "Административные дела",
|
||||
desc: "Профессиональное представительство интересов в административных спорах и ДТП.",
|
||||
href: "/services/administrative",
|
||||
icon: "shield"
|
||||
},
|
||||
{
|
||||
title: "Арбитражные дела",
|
||||
desc: "Защита интересов бизнеса в арбитражных судах всех инстанций.",
|
||||
href: "/services/arbitration",
|
||||
icon: "briefcase"
|
||||
},
|
||||
{
|
||||
title: "Защита должников",
|
||||
desc: "Защита от финансовых претензий банков и коллекторских организаций.",
|
||||
href: "/services/debt-protection",
|
||||
icon: "protect"
|
||||
}
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
scale: "M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3",
|
||||
balance: "M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm-1-13h2v6h-2zm0 8h2v2h-2z",
|
||||
users: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
shield: "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
briefcase: "M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
|
||||
protect: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||
};
|
||||
---
|
||||
|
||||
<section id="services" class="relative py-24 bg-white overflow-hidden">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-gray-50 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/10 text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider rounded-full mb-4">
|
||||
Специализация
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
Направления <span class="text-[var(--color-blue-primary)]">практики</span>
|
||||
</h2>
|
||||
<div class="w-24 h-1.5 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] mx-auto rounded-full"></div>
|
||||
<p class="mt-6 text-gray-600 max-w-2xl mx-auto">
|
||||
Специализируюсь на представлении интересов в самых сложных правовых спорах на территории Сургута и ХМАО-Югры
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Сетка -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{services.map((item, index) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="group relative bg-white/80 backdrop-blur-xl p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 hover:-translate-y-2 hover:border-[var(--color-blue-primary)]/20 transition-all duration-500 overflow-hidden"
|
||||
>
|
||||
<!-- Декоративный градиент -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)]/5 via-transparent to-[var(--color-gold)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="relative w-16 h-16 bg-gradient-to-br from-[var(--color-blue-primary)] to-blue-600 rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-blue-500/30 group-hover:scale-110 group-hover:shadow-xl transition-all duration-300">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[item.icon]}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<h3 class="relative text-xl font-bold text-gray-900 mb-3 group-hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<p class="relative text-gray-600 leading-relaxed mb-6">
|
||||
{item.desc}
|
||||
</p>
|
||||
|
||||
<!-- Ссылка -->
|
||||
<div class="relative flex items-center gap-2 text-[var(--color-gold)] font-bold text-sm uppercase tracking-wider group-hover:gap-3 transition-all">
|
||||
<span>Подробнее</span>
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
105
frontend/src/components/home/Stats.astro
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
import { getDynamicStats, getYearDeclension, getCaseDeclension, getClientDeclension } from '@utils/stats.utils.ts';
|
||||
|
||||
const stats = getDynamicStats();
|
||||
|
||||
const statIcons = [
|
||||
"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", // Calendar
|
||||
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z", // Check circle
|
||||
"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z", // Users
|
||||
"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" // Clock
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-20 overflow-hidden bg-gray-50">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-[var(--color-blue-primary)] opacity-5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => (
|
||||
<div class="group relative bg-white/80 backdrop-blur-xl p-8 rounded-2xl shadow-lg border border-white/50 hover:shadow-2xl hover:shadow-[var(--color-blue-primary)]/10 hover:-translate-y-1 transition-all duration-500 text-center overflow-hidden">
|
||||
<!-- Декоративный фон при hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-blue-primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="relative w-14 h-14 mx-auto mb-4 bg-[var(--color-blue-primary)]/10 rounded-xl flex items-center justify-center group-hover:bg-[var(--color-blue-primary)] transition-colors duration-300">
|
||||
<svg class="w-7 h-7 text-[var(--color-blue-primary)] group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={statIcons[index]}/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Цифра -->
|
||||
<div class="flex items-baseline justify-center gap-1 mb-2">
|
||||
<span
|
||||
class="stat-counter text-4xl font-black text-gray-900 tracking-tight"
|
||||
data-target={stat.number}
|
||||
>
|
||||
0
|
||||
</span>
|
||||
<span class="text-2xl font-bold text-[var(--color-blue-primary)]">{stat.suffix}</span>
|
||||
</div>
|
||||
|
||||
<!-- Текст -->
|
||||
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
{stat.text.includes('Лет практики') ? `${stat.number} ${getYearDeclension(stat.number)} практики` :
|
||||
stat.text.includes('Успешных дел') ? `${stat.number} ${stat.suffix} ${stat.text.toLowerCase()}` :
|
||||
stat.text.includes('Клиентов') ? `${stat.number} довольных ${getClientDeclension(stat.number)}` :
|
||||
stat.text.includes('На связи') ? 'в режиме 24/7' : stat.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const animateStats = () => {
|
||||
const counters = document.querySelectorAll('.stat-counter');
|
||||
const speed = 2000;
|
||||
|
||||
const startAnimation = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const counter = entry.target as HTMLElement;
|
||||
const target = +counter.getAttribute('data-target')!;
|
||||
|
||||
let startTime: number | null = null;
|
||||
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = timestamp - startTime;
|
||||
const percentage = Math.min(progress / speed, 1);
|
||||
const ease = 1 - Math.pow(1 - percentage, 3);
|
||||
const current = Math.floor(ease * target);
|
||||
|
||||
counter.innerText = current.toString();
|
||||
|
||||
if (percentage < 1) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
counter.innerText = target.toString();
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
observer.unobserve(counter);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(startAnimation, {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
counters.forEach(counter => observer.observe(counter));
|
||||
};
|
||||
|
||||
animateStats();
|
||||
document.addEventListener('astro:after-swap', animateStats);
|
||||
</script>
|
||||
114
frontend/src/components/home/WhyUs.astro
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { PRACTICE_START_YEAR } from '@constants/constants.ts';
|
||||
import { getYearDeclension } from '@utils/stats.utils.ts';
|
||||
import whyUsImage from '@assets/images/home/whyus/WhyUs.png';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearsOfPractice = currentYear - PRACTICE_START_YEAR;
|
||||
const yearDeclension = getYearDeclension(yearsOfPractice);
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "Абсолютная анонимность",
|
||||
desc: "Строгое соблюдение адвокатской тайны. Любая информация остается полностью конфиденциальной.",
|
||||
icon: "shield-check"
|
||||
},
|
||||
{
|
||||
title: "Глубокая экспертиза",
|
||||
desc: "Опыт включает сотни выигранных дел в различных инстанциях, от мировых судов до Верховного Суда РФ.",
|
||||
icon: "academic-cap"
|
||||
},
|
||||
{
|
||||
title: "Прозрачные условия",
|
||||
desc: "Фиксированная стоимость услуг, прописанная в договоре. Никаких скрытых платежей.",
|
||||
icon: "currency"
|
||||
}
|
||||
];
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
"shield-check": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
"academic-cap": "M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z M12 14L4.5 8.25",
|
||||
"currency": "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative py-24 bg-gray-50 overflow-hidden">
|
||||
<!-- Декоративный фон -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-1/2 left-0 w-96 h-96 bg-[var(--color-gold)] opacity-5 rounded-full blur-3xl -translate-y-1/2"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl relative z-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<!-- Левая колонка: Изображение -->
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-4 bg-gradient-to-br from-[var(--color-gold)]/20 to-[var(--color-blue-primary)]/20 rounded-3xl blur-2xl opacity-50 group-hover:opacity-70 transition-opacity"></div>
|
||||
|
||||
<div class="relative rounded-2xl overflow-hidden shadow-2xl">
|
||||
<Image
|
||||
src={whyUsImage}
|
||||
alt="Адвокатский стол"
|
||||
class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700"
|
||||
width="800"
|
||||
height="600"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<!-- Плавающая карточка -->
|
||||
<div class="absolute bottom-8 right-8 bg-white/95 backdrop-blur-xl p-6 rounded-2xl shadow-2xl border border-white/50 max-w-[240px]">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-xl flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<span class="text-2xl font-bold text-white">{yearsOfPractice}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Опыт работы</span>
|
||||
<span class="text-sm text-gray-900 font-semibold leading-tight">{yearDeclension} безупречной практики</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка: Контент -->
|
||||
<div>
|
||||
<div class="mb-12">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-blue-primary)]/10 text-[var(--color-blue-primary)] text-xs font-bold uppercase tracking-wider rounded-full mb-4">
|
||||
Наше преимущество
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
|
||||
Почему выбирают <span class="text-[var(--color-blue-primary)]">нас?</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
{features.map((item, index) => (
|
||||
<div class="flex gap-6 group">
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-14 h-14 bg-white rounded-2xl shadow-lg border border-gray-100 flex items-center justify-center group-hover:bg-[var(--color-blue-primary)] group-hover:border-[var(--color-blue-primary)] transition-all duration-300">
|
||||
<svg class="w-6 h-6 text-[var(--color-blue-primary)] group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[item.icon]}/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Линия соединительная -->
|
||||
{index !== features.length - 1 && (
|
||||
<div class="absolute top-14 left-1/2 w-px h-8 bg-gray-200 -translate-x-1/2"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2 group-hover:text-[var(--color-blue-primary)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
170
frontend/src/components/layouts/footer/Footer.astro
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
import SocialIcons from "@components/base/SocialIcons.astro";
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const services = [
|
||||
{ name: "Административные дела", href: "/services/arbitration" },
|
||||
{ name: "Гражданские дела", href: "/services/civil" },
|
||||
{ name: "Семейные дела", href: "/services/family" },
|
||||
{ name: "Защита должников", href: "/services/debt-protection" },
|
||||
];
|
||||
|
||||
const menu = [
|
||||
{ name: "Главная", href: "/" },
|
||||
{ name: "О бюро", href: "/about" },
|
||||
{ name: "Отзывы", href: "/reviews" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
];
|
||||
---
|
||||
|
||||
<footer
|
||||
class="bg-[#151b26] text-gray-400 py-16 border-t border-white/5 font-sans"
|
||||
>
|
||||
<div class="container mx-auto px-6 md:px-12 lg:px-16">
|
||||
<!-- Основная сетка: 4 колонки -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 lg:gap-8 mb-20"
|
||||
>
|
||||
<!-- Колонка 1: Лого и Инфо -->
|
||||
<div class="flex flex-col items-center md:items-start">
|
||||
<!-- Логотип -->
|
||||
<a href="/" class="flex items-center gap-3 mb-6 group">
|
||||
<div
|
||||
class="w-10 h-10 bg-[#1e2532] border border-white/10 flex items-center justify-center rounded-sm group-hover:border-[#bf9b58] transition-colors duration-300"
|
||||
>
|
||||
<!-- Иконка весов -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[#bf9b58]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"
|
||||
></path><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"
|
||||
></path><path d="M7 21h10"></path><path d="M12 3v18"></path><path
|
||||
d="M3 7h2v2H3z"></path><path d="M19 7h2v2h-2z"></path></svg
|
||||
>
|
||||
</div>
|
||||
<span class="text-xl font-black text-white uppercase tracking-tighter"
|
||||
>ADVOKAT086</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<p
|
||||
class="text-sm leading-relaxed mb-8 max-w-xs text-gray-400 text-center md:text-left"
|
||||
>
|
||||
Ведущая адвокатская практика в Сургуте. Обеспечиваем правовую защиту
|
||||
бизнеса и граждан с 2009 года.
|
||||
</p>
|
||||
|
||||
<!-- Социальные иконки -->
|
||||
<SocialIcons variant="footer" />
|
||||
</div>
|
||||
|
||||
<!-- Колонка 2: Услуги -->
|
||||
<div class="text-center md:text-left">
|
||||
<h3
|
||||
class="text-[#bf9b58] text-xs font-bold uppercase tracking-[0.2em] mb-8"
|
||||
>
|
||||
Услуги
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
{
|
||||
services.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-sm font-bold uppercase text-gray-300 hover:text-[#bf9b58] transition-colors duration-300 tracking-wide"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Колонка 3: Меню -->
|
||||
<div class="hidden md:block">
|
||||
<h3
|
||||
class="text-[#bf9b58] text-xs font-bold uppercase tracking-[0.2em] mb-8"
|
||||
>
|
||||
Меню
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
{
|
||||
menu.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-sm font-bold uppercase text-gray-300 hover:text-[#bf9b58] transition-colors duration-300 tracking-wide"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Колонка 4: Контакты -->
|
||||
<div class="text-center md:text-left">
|
||||
<h3
|
||||
class="text-[#bf9b58] text-xs font-bold uppercase tracking-[0.2em] mb-8"
|
||||
>
|
||||
Контакты
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] uppercase tracking-widest text-gray-500 mb-1">
|
||||
Телефон
|
||||
</p>
|
||||
<a
|
||||
href={CONTACT_CONSTANTS.phoneHref}
|
||||
class="text-white text-lg font-bold hover:text-[#bf9b58] transition-colors"
|
||||
>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-[10px] uppercase tracking-widest text-gray-500 mb-1">
|
||||
Адрес
|
||||
</p>
|
||||
<address class="text-white font-bold not-italic">
|
||||
{CONTACT_CONSTANTS.address}
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Нижняя линия: Copyright -->
|
||||
<div
|
||||
class="pt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-[10px] uppercase tracking-[0.1em] text-gray-600">
|
||||
© {currentYear} ADVOKAT086. Все права защищены.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<a
|
||||
href="/privacy-policy"
|
||||
class="text-[10px] uppercase tracking-[0.1em] text-gray-600 hover:text-white transition-colors"
|
||||
>
|
||||
Политика конфиденциальности
|
||||
</a>
|
||||
<a
|
||||
href="/legal-info"
|
||||
class="text-[10px] uppercase tracking-[0.1em] text-gray-600 hover:text-white transition-colors"
|
||||
>
|
||||
Правовая информация
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
444
frontend/src/components/layouts/header/Header.astro
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
---
|
||||
import MobileMenu from "./MobileMenu.astro";
|
||||
import Button from "@components/base/Button.astro";
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
const { pathname } = Astro.url;
|
||||
|
||||
// Проверка авторизации будет на клиенте
|
||||
const navLinks = [
|
||||
{ name: "Главная", href: "/" },
|
||||
{
|
||||
name: "Услуги",
|
||||
href: "/services",
|
||||
children: [
|
||||
{
|
||||
name: "Административные дела",
|
||||
href: "/services/administrative",
|
||||
icon: "⚖️",
|
||||
},
|
||||
{
|
||||
name: "Защита должников",
|
||||
href: "/services/debt-protection",
|
||||
icon: "💳",
|
||||
},
|
||||
{
|
||||
name: "Арбитражные дела",
|
||||
href: "/services/arbitration",
|
||||
icon: "💼",
|
||||
},
|
||||
{
|
||||
name: "Уголовные дела",
|
||||
href: "/services/criminal",
|
||||
icon: "⚖️",
|
||||
},
|
||||
{ name: "Гражданские дела", href: "/services/civil", icon: "📋" },
|
||||
{ name: "Семейные дела", href: "/services/family", icon: "👨👩👧" },
|
||||
{ name: "Дела СВО", href: "/services/svo", icon: "🛡️" },
|
||||
],
|
||||
},
|
||||
{ name: "Кейсы", href: "/cases" },
|
||||
{ name: "Блог", href: "/blog" },
|
||||
{ name: "О Бюро", href: "/about" },
|
||||
{ name: "Контакты", href: "/contacts" },
|
||||
];
|
||||
|
||||
const rawLinks = navLinks.filter((link) => {
|
||||
if (pathname === "/" && link.href === "/") return false;
|
||||
return true;
|
||||
});
|
||||
---
|
||||
|
||||
<header
|
||||
id="main-header"
|
||||
class="sticky top-0 w-full py-4 px-4 md:px-8 lg:px-16 z-[100] bg-[#0a0f1c]/90 backdrop-blur-md border-b border-white/5 transition-all duration-300"
|
||||
>
|
||||
<div class="container px-4 md:px-8 mx-auto flex items-center justify-between">
|
||||
<!-- Логотип -->
|
||||
<a href="/" class="flex items-center group relative py-2">
|
||||
<div
|
||||
class="w-0 opacity-0 overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,1,0.5,1)] group-hover:w-10 group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 min-w-10 bg-[#bf9b58] flex items-center justify-center rounded"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[#151b26]"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12c5.16-1.26 9-6.45 9-12V5zm0 4a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m5.13 12A9.7 9.7 0 0 1 12 20.92A9.7 9.7 0 0 1 6.87 17c-.34-.5-.63-1-.87-1.53c0-1.65 2.71-3 6-3s6 1.32 6 3c-.24.53-.53 1.03-.87 1.53"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col transition-all duration-500 ease-[cubic-bezier(0.25,1,0.5,1)] pl-0 group-hover:pl-3"
|
||||
>
|
||||
<span
|
||||
class="text-xl md:text-2xl font-black tracking-tighter text-white uppercase leading-none"
|
||||
>
|
||||
ADVOKAT<span class="text-[#bf9b58]">086</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] text-gray-400 tracking-[0.2em] uppercase hidden sm:block"
|
||||
>Юридическая защита</span
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Десктоп меню -->
|
||||
<nav class="hidden lg:flex items-center gap-8 xl:gap-12">
|
||||
{
|
||||
rawLinks.map((link) => (
|
||||
<div class="relative group">
|
||||
{link.children ? (
|
||||
<>
|
||||
<button
|
||||
class={`flex items-center gap-2 text-sm md:text-base font-bold uppercase tracking-wide transition-colors duration-300 py-2 ${link.active ? "text-[#bf9b58]" : "text-gray-300 hover:text-[#bf9b58]"}`}
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{link.name}
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-300 ease-out group-hover:-rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 pt-4 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 ease-out transform group-hover:translate-y-0 translate-y-2 min-w-[300px]">
|
||||
<div class="bg-[#0f1623]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl shadow-black/50 overflow-hidden p-2 relative">
|
||||
<div class="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-[#0f1623] border-l border-t border-white/10 rotate-45" />
|
||||
<div class="relative space-y-1">
|
||||
{link.children.map((child) => (
|
||||
<a
|
||||
href={child.href}
|
||||
class={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === child.href ? "bg-[#bf9b58]/10 text-[#bf9b58]" : "text-gray-300 hover:bg-white/5 hover:text-[#bf9b58]"}`}
|
||||
>
|
||||
{child.icon && (
|
||||
<span class="text-lg flex-shrink-0">
|
||||
{child.icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{child.name}</span>
|
||||
{pathname === child.href && (
|
||||
<svg
|
||||
class="w-4 h-4 ml-auto text-[#bf9b58]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div class="mt-2 pt-2 border-t border-white/5">
|
||||
<a
|
||||
href={link.href}
|
||||
class="flex items-center justify-center gap-2 px-4 py-2 text-xs font-bold uppercase tracking-wider text-[#bf9b58] hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Все услуги
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
class={`text-sm md:text-base font-bold uppercase tracking-wide transition-colors duration-300 py-2 block ${link.active ? "text-[#bf9b58]" : "text-gray-300 hover:text-[#bf9b58]"}`}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<!-- Блок авторизации - показывается/скрывается через JS -->
|
||||
<div id="auth-block" class="hidden xl:flex items-center gap-3">
|
||||
<!-- Кнопки для авторизованного пользователя -->
|
||||
<div id="auth-user-block" class="hidden items-center gap-3">
|
||||
<a
|
||||
href="/profile"
|
||||
class="w-10 h-10 rounded-full bg-[#bf9b58]/20 border border-[#bf9b58]/50 flex items-center justify-center hover:bg-[#bf9b58]/30 transition-colors group"
|
||||
title="Личный кабинет"
|
||||
>
|
||||
<span id="user-initial" class="text-white font-bold text-lg group-hover:text-[#bf9b58]"></span>
|
||||
</a>
|
||||
<button
|
||||
id="logout-btn"
|
||||
class="w-10 h-10 rounded-full border border-red-500/30 flex items-center justify-center hover:bg-red-500/10 transition-colors group"
|
||||
title="Выход"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-red-400 group-hover:text-red-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Блок для неавторизованного -->
|
||||
<div id="auth-guest-block" class="flex flex-col items-end">
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="flex items-center gap-2 text-white font-bold text-lg hover:text-[#bf9b58] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
<span class="text-[9px] text-gray-400 font-bold uppercase tracking-widest">
|
||||
Онлайн
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ИЗМЕНЕНИЕ: Кнопка-бургер. Увеличена область (w-12 h-12), добавлен rounded-full -->
|
||||
<button
|
||||
id="menu-toggle"
|
||||
class="lg:hidden relative w-12 h-12 rounded-full border border-transparent flex items-center justify-center z-[1000] cursor-pointer transition-all duration-300"
|
||||
aria-label="Меню"
|
||||
aria-expanded="false"
|
||||
onclick="
|
||||
const body = document.body;
|
||||
const isOpen = body.classList.toggle('menu-open');
|
||||
this.setAttribute('aria-expanded', isOpen);
|
||||
body.style.overflow = isOpen ? 'hidden' : '';
|
||||
"
|
||||
>
|
||||
<span class="burger-line line-1"></span>
|
||||
<span class="burger-line line-2"></span>
|
||||
<span class="burger-line line-3"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="scroll-progress"
|
||||
class="absolute bottom-0 left-0 h-0.5 bg-[#bf9b58] w-0 transition-all duration-75 ease-out z-50"
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileMenu links={rawLinks} />
|
||||
|
||||
<style>
|
||||
/* Шапка становится прозрачной, чтобы просвечивал темный оверлей из меню */
|
||||
:global(body.menu-open) #main-header {
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ИЗМЕНЕНИЕ: Стили для активной кнопки (круглая подложка) */
|
||||
#menu-toggle[aria-expanded="true"] {
|
||||
background-color: #151b26; /* Темный фон под крестиком */
|
||||
border-color: rgba(255, 255, 255, 0.1); /* Легкая граница */
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); /* Тень для объема */
|
||||
}
|
||||
|
||||
.burger-line {
|
||||
position: absolute;
|
||||
width: 26px; /* Немного уменьшили ширину линий, чтобы они лучше вписывались в круг */
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
border-radius: 2px;
|
||||
transition: all 0.4s cubic-bezier(0.68, -0.6, 0.32, 1.6);
|
||||
}
|
||||
|
||||
.line-1 {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.line-2 {
|
||||
width: 18px;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.line-3 {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* Крестик */
|
||||
#menu-toggle[aria-expanded="true"] .line-1 {
|
||||
transform: translateY(0) rotate(45deg);
|
||||
background-color: #bf9b58; /* Делаем крестик золотым */
|
||||
width: 22px; /* Аккуратный крестик */
|
||||
}
|
||||
|
||||
#menu-toggle[aria-expanded="true"] .line-2 {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
#menu-toggle[aria-expanded="true"] .line-3 {
|
||||
transform: translateY(0) rotate(-45deg);
|
||||
background-color: #bf9b58;
|
||||
width: 22px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const initApp = () => {
|
||||
const progressBar = document.getElementById("scroll-progress");
|
||||
if (progressBar) {
|
||||
const updateScrollProgress = () => {
|
||||
const scrollTop =
|
||||
document.documentElement.scrollTop || document.body.scrollTop;
|
||||
const scrollHeight =
|
||||
document.documentElement.scrollHeight -
|
||||
document.documentElement.clientHeight;
|
||||
const scrollPercent =
|
||||
scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
|
||||
progressBar.style.width = `${scrollPercent}%`;
|
||||
};
|
||||
window.addEventListener("scroll", updateScrollProgress, {
|
||||
passive: true,
|
||||
});
|
||||
updateScrollProgress();
|
||||
}
|
||||
|
||||
// Проверка авторизации
|
||||
const checkAuth = async () => {
|
||||
const authBlock = document.getElementById("auth-block");
|
||||
const authUserBlock = document.getElementById("auth-user-block");
|
||||
const authGuestBlock = document.getElementById("auth-guest-block");
|
||||
const userInitialEl = document.getElementById("user-initial");
|
||||
|
||||
if (!authBlock || !authUserBlock || !authGuestBlock) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
authBlock.classList.remove("hidden");
|
||||
authUserBlock.classList.remove("hidden");
|
||||
authUserBlock.classList.add("flex");
|
||||
authGuestBlock.classList.add("hidden");
|
||||
authGuestBlock.classList.remove("flex");
|
||||
|
||||
// Отображаем первую букву имени пользователя
|
||||
if (userInitialEl && data.user?.name) {
|
||||
const firstLetter = data.user.name.trim().charAt(0).toUpperCase();
|
||||
userInitialEl.textContent = firstLetter;
|
||||
}
|
||||
} else {
|
||||
authBlock.classList.remove("hidden");
|
||||
authUserBlock.classList.add("hidden");
|
||||
authUserBlock.classList.remove("flex");
|
||||
authGuestBlock.classList.remove("hidden");
|
||||
authGuestBlock.classList.add("flex");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Header] Ошибка проверки авторизации:', error);
|
||||
authBlock.classList.remove("hidden");
|
||||
authUserBlock.classList.add("hidden");
|
||||
authGuestBlock.classList.remove("hidden");
|
||||
authGuestBlock.classList.add("flex");
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
// Кнопка выхода
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) {
|
||||
logoutBtn.classList.add("cursor-pointer");
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
console.log('[Header] Выход из системы');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Удаляем куку на клиенте
|
||||
document.cookie = 'pb_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
// Перенаправляем на главную
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Header] Ошибка при выходе:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("close-mobile-menu", () => {
|
||||
const toggleBtn = document.getElementById("menu-toggle");
|
||||
document.body.classList.remove("menu-open");
|
||||
document.body.style.overflow = "";
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
|
||||
initApp();
|
||||
document.addEventListener("astro:page-load", initApp);
|
||||
</script>
|
||||
296
frontend/src/components/layouts/header/MobileMenu.astro
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
---
|
||||
import Button from "@components/base/Button.astro";
|
||||
|
||||
const { links } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="menu-overlay"
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[90] opacity-0 pointer-events-none transition-opacity duration-500"
|
||||
onclick="document.dispatchEvent(new CustomEvent('close-mobile-menu'))"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- ИЗМЕНЕНИЕ: Ширина уменьшена с 85% до 80% (w-[80%]) -->
|
||||
<aside
|
||||
id="mobile-menu"
|
||||
class="fixed top-0 left-0 h-full w-[80%] max-w-[400px] bg-[#151b26] z-[105] py-8 px-6 flex flex-col overflow-y-auto shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none opacity-5 bg-gradient-to-br from-white/5 to-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Логотип -->
|
||||
<div class="mobile-link mb-10 relative z-10" style="--delay: 0.1s">
|
||||
<span
|
||||
class="text-xl md:text-2xl font-black tracking-tighter text-white uppercase leading-none"
|
||||
>
|
||||
ADVOKAT<span class="text-[#bf9b58]">086</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Навигация -->
|
||||
<nav class="relative z-10 flex flex-col items-center space-y-5 w-full flex-1">
|
||||
{
|
||||
links.map((link: any, index: number) =>
|
||||
link.children ? (
|
||||
<div
|
||||
class="mobile-link w-full flex flex-col items-center"
|
||||
style={`--delay: ${0.2 + index * 0.08}s`}
|
||||
>
|
||||
<button
|
||||
class={`mobile-submenu-toggle text-xl md:text-2xl font-black uppercase tracking-wider transition-colors text-center flex items-center gap-2 ${link.active ? "text-[#bf9b58]" : "text-white hover:text-[#bf9b58]"}`}
|
||||
data-index={index}
|
||||
>
|
||||
{link.name}
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-300 arrow-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="mobile-submenu hidden mt-3 w-full space-y-2 overflow-hidden"
|
||||
data-index={index}
|
||||
>
|
||||
{link.children.map((child: any) => (
|
||||
<a
|
||||
href={child.href}
|
||||
class="block text-center text-base font-bold uppercase tracking-wider text-gray-300 hover:text-[#bf9b58] transition-colors py-1"
|
||||
onclick="document.dispatchEvent(new CustomEvent('close-mobile-menu'))"
|
||||
>
|
||||
<span class="mr-2">{child.icon}</span>
|
||||
{child.name}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
href={link.href}
|
||||
class="block text-center text-sm font-bold uppercase tracking-wider text-[#bf9b58] hover:text-white transition-colors mt-2 py-1"
|
||||
onclick="document.dispatchEvent(new CustomEvent('close-mobile-menu'))"
|
||||
>
|
||||
Все услуги →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
class="mobile-link text-xl md:text-2xl font-black uppercase tracking-wider transition-colors text-center"
|
||||
class:list={[
|
||||
link.active
|
||||
? "text-[#bf9b58]"
|
||||
: "text-white hover:text-[#bf9b58]",
|
||||
]}
|
||||
style={`--delay: ${0.2 + index * 0.08}s`}
|
||||
onclick="document.dispatchEvent(new CustomEvent('close-mobile-menu'))"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Авторизация - управляется через JS -->
|
||||
<div
|
||||
id="mobile-auth-block"
|
||||
class="mobile-link w-full mt-8 flex justify-center gap-6 relative z-10 hidden"
|
||||
style="--delay: 0.6s"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
class="w-10 h-10 rounded-full bg-[#bf9b58]/20 border border-[#bf9b58]/50 flex items-center justify-center hover:bg-[#bf9b58]/30 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[#bf9b58]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
id="mobile-logout-btn"
|
||||
class="w-10 h-10 rounded-full border border-red-500/30 flex items-center justify-center hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Нижний блок -->
|
||||
<div
|
||||
class="relative z-10 w-full mt-auto pt-6 mobile-link"
|
||||
style="--delay: 0.7s"
|
||||
>
|
||||
<div class="h-[1px] bg-white/10 w-full mb-4"></div>
|
||||
<div id="mobile-guest-block" class="space-y-0.5 text-center hidden">
|
||||
<p class="text-xs font-bold text-[#bf9b58] uppercase tracking-[0.2em]">
|
||||
Срочная связь
|
||||
</p>
|
||||
<a
|
||||
href="tel:+79222538375"
|
||||
class="block text-lg font-bold text-white hover:text-[#bf9b58] transition-colors"
|
||||
>
|
||||
+7 (922) 253-83-75
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
#mobile-menu {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
:global(body.menu-open) #mobile-menu {
|
||||
transform: translateX(0);
|
||||
}
|
||||
:global(body.menu-open) #menu-overlay {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.mobile-link {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition:
|
||||
opacity 0.4s ease-out,
|
||||
transform 0.4s ease-out;
|
||||
transition-delay: var(--delay);
|
||||
}
|
||||
:global(body.menu-open) .mobile-link {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.arrow-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const setupSubmenus = () => {
|
||||
const toggles = document.querySelectorAll(".mobile-submenu-toggle");
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const index = toggle.getAttribute("data-index");
|
||||
const submenu = document.querySelector(
|
||||
`.mobile-submenu[data-index="${index}"]`,
|
||||
);
|
||||
const arrow = toggle.querySelector(".arrow-icon");
|
||||
|
||||
if (submenu) {
|
||||
const isHidden = submenu.classList.contains("hidden");
|
||||
document
|
||||
.querySelectorAll(".mobile-submenu")
|
||||
.forEach((el) => el.classList.add("hidden"));
|
||||
document
|
||||
.querySelectorAll(".arrow-icon")
|
||||
.forEach((el) => el.classList.remove("rotated"));
|
||||
|
||||
if (isHidden) {
|
||||
submenu.classList.remove("hidden");
|
||||
arrow?.classList.add("rotated");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const setupLogout = () => {
|
||||
const btn = document.getElementById("mobile-logout-btn");
|
||||
if (btn) {
|
||||
btn.classList.add("cursor-pointer");
|
||||
btn.addEventListener("click", async () => {
|
||||
console.log('[MobileMenu] Выход из системы');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Удаляем куку на клиенте
|
||||
document.cookie = 'pb_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
// Перенаправляем на главную
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MobileMenu] Ошибка при выходе:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка авторизации
|
||||
const checkAuth = async () => {
|
||||
const mobileAuthBlock = document.getElementById("mobile-auth-block");
|
||||
const mobileGuestBlock = document.getElementById("mobile-guest-block");
|
||||
|
||||
if (!mobileAuthBlock || !mobileGuestBlock) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
// Пользователь авторизован
|
||||
mobileAuthBlock.classList.remove("hidden");
|
||||
mobileAuthBlock.classList.add("flex");
|
||||
mobileGuestBlock.classList.add("hidden");
|
||||
mobileGuestBlock.classList.remove("flex");
|
||||
} else {
|
||||
// Пользователь не авторизован
|
||||
mobileAuthBlock.classList.add("hidden");
|
||||
mobileAuthBlock.classList.remove("flex");
|
||||
mobileGuestBlock.classList.remove("hidden");
|
||||
mobileGuestBlock.classList.add("flex");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MobileMenu] Ошибка проверки авторизации:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Вызываем проверку авторизации
|
||||
checkAuth();
|
||||
|
||||
setupSubmenus();
|
||||
setupLogout();
|
||||
document.addEventListener("astro:after-swap", () => {
|
||||
setupSubmenus();
|
||||
setupLogout();
|
||||
checkAuth();
|
||||
});
|
||||
</script>
|
||||
729
frontend/src/components/reviews/AuthorizedReviewModal.astro
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
---
|
||||
import Toast from "@components/base/Toast.astro";
|
||||
---
|
||||
|
||||
<!-- Модальное окно для написания отзыва с проверкой авторизации -->
|
||||
<div
|
||||
id="review-modal"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] hidden items-center justify-center p-4 overflow-y-auto"
|
||||
>
|
||||
<div class="bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-2xl p-8 max-w-2xl w-full relative shadow-2xl animate-fade-in my-8">
|
||||
<!-- Кнопка закрытия -->
|
||||
<button
|
||||
id="close-review-modal"
|
||||
class="absolute top-4 right-4 w-8 h-8 rounded-full bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 hover:border-[var(--color-gold)]/30 flex items-center justify-center transition-all cursor-pointer"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[var(--color-gray-400)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Блок для неавторизованного пользователя -->
|
||||
<div id="auth-required-block" class="h-full flex flex-col items-center justify-center text-center py-12">
|
||||
<!-- Иконка замка -->
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-[var(--color-gold)] to-[var(--color-gold-hover)] rounded-full flex items-center justify-center mb-6 shadow-lg shadow-[var(--color-gold)]/30">
|
||||
<svg class="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-2xl font-bold text-[var(--color-white)] mb-3">
|
||||
Требуется авторизация
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-400)] text-base mb-8 max-w-md">
|
||||
Для написания отзыва необходимо войти в систему или зарегистрироваться.
|
||||
Это обеспечит достоверность и защиту от спама.
|
||||
</p>
|
||||
|
||||
<!-- Кнопки действий -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<a
|
||||
id="login-link"
|
||||
href="/auth/login"
|
||||
class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-white font-bold rounded-xl shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Войти
|
||||
</a>
|
||||
|
||||
<a
|
||||
id="register-link"
|
||||
href="/auth/register"
|
||||
class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-[var(--color-navy)] border-2 border-[var(--color-gold)] text-[var(--color-gold)] font-bold rounded-xl hover:bg-[var(--color-gold)] hover:text-white transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
Зарегистрироваться
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Дополнительная информация -->
|
||||
<div class="mt-8 pt-8 border-t border-[var(--color-gray-600)]/20">
|
||||
<p class="text-xs text-[var(--color-gray-500)]">
|
||||
Уже есть аккаунт?{" "}
|
||||
<a id="login-link-inline" href="/auth/login" class="text-[var(--color-gold)] font-medium hover:underline">
|
||||
Войти сейчас
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Блок для авторизованного пользователя (форма) -->
|
||||
<div id="review-form-block" class="hidden">
|
||||
<!-- Заголовок -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-[var(--color-white)] mb-2">
|
||||
Написать отзыв
|
||||
</h3>
|
||||
<p class="text-[var(--color-gray-400)] text-sm">
|
||||
Поделитесь своим опытом работы с нами. Ваш отзыв поможет другим людям сделать правильный выбор.
|
||||
</p>
|
||||
<!-- Приветствие пользователя -->
|
||||
<div id="user-greeting" class="mt-4 p-4 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl">
|
||||
<p class="text-[var(--color-white)] font-medium">
|
||||
Привет, <span id="user-name-display" class="text-[var(--color-gold)]">...</span>!
|
||||
</p>
|
||||
<p class="text-[var(--color-gray-400)] text-sm mt-1">
|
||||
Вы решили написать отзыв?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<form id="review-form" class="space-y-5" novalidate>
|
||||
<!-- Должность/Профессия -->
|
||||
<div>
|
||||
<label for="review-role" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Должность <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="review-role"
|
||||
name="role"
|
||||
placeholder="Например: Предприниматель, Врач, Учитель"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
pattern="^[а-яА-ЯёЁa-zA-Z\s\-]+$"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium placeholder-[var(--color-gray-600)] focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span id="role-error" class="text-red-500 text-xs mt-1 hidden"></span>
|
||||
</div>
|
||||
|
||||
<!-- Категория дела -->
|
||||
<div>
|
||||
<label for="review-case-type" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Категория дела <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="review-case-type"
|
||||
name="caseType"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="" disabled selected>Выберите категорию</option>
|
||||
<option value="Семейные дела">Семейные дела</option>
|
||||
<option value="Административные дела">Административные дела</option>
|
||||
<option value="Уголовные дела">Уголовные дела</option>
|
||||
<option value="Гражданские дела">Гражданские дела</option>
|
||||
<option value="Арбитражные дела">Арбитражные дела</option>
|
||||
<option value="Защита должников">Защита должников</option>
|
||||
<option value="Дела СВО">Дела СВО</option>
|
||||
</select>
|
||||
<span id="caseType-error" class="text-red-500 text-xs mt-1 hidden"></span>
|
||||
</div>
|
||||
|
||||
<!-- Рейтинг -->
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Оценка <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 items-center" id="rating-stars">
|
||||
<button type="button" data-rating="1" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 1 звезду">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="2" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 2 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="3" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 3 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="4" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 4 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="5" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 5 звезд">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Подсказка если рейтинг не выбран -->
|
||||
<span id="rating-hint" class="text-xs text-[var(--color-gold)] ml-2 opacity-0 transition-opacity duration-300">
|
||||
⬅️ Выберите оценку
|
||||
</span>
|
||||
</div>
|
||||
<input type="hidden" id="review-rating" name="rating" value="0" />
|
||||
<span id="rating-error" class="text-red-500 text-xs mt-1 hidden">Поставьте оценку</span>
|
||||
</div>
|
||||
|
||||
<!-- Текст отзыва -->
|
||||
<div>
|
||||
<label for="review-text" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Текст отзыва <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-text"
|
||||
name="text"
|
||||
placeholder="Расскажите о вашем опыте работы с нами..."
|
||||
required
|
||||
minlength="10"
|
||||
rows="5"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium placeholder-[var(--color-gray-600)] focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all resize-none"
|
||||
></textarea>
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span id="text-error" class="text-red-500 text-xs hidden"></span>
|
||||
<span id="char-count" class="text-xs text-[var(--color-gray-600)]">0 / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-review-btn"
|
||||
disabled
|
||||
class="w-full py-3 bg-[var(--color-gray-600)] text-[var(--color-gray-400)] font-bold rounded-xl transition-all cursor-not-allowed opacity-50"
|
||||
data-enabled-class="bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] hover:shadow-lg hover:shadow-[var(--color-gold)]/30 hover:cursor-pointer"
|
||||
>
|
||||
Отправить отзыв
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast компонент -->
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
#rating-stars .star-btn.active svg {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Убираем стандартную стрелку у select */
|
||||
#review-case-type {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { loadAuthFromCookie } from '@lib/auth.ts';
|
||||
|
||||
// Элементы
|
||||
const reviewModal = document.getElementById("review-modal");
|
||||
const closeReviewModalBtn = document.getElementById("close-review-modal");
|
||||
const authRequiredBlock = document.getElementById("auth-required-block");
|
||||
const reviewFormBlock = document.getElementById("review-form-block");
|
||||
const reviewForm = document.getElementById("review-form");
|
||||
const starBtns = document.querySelectorAll(".star-btn");
|
||||
const ratingInput = document.getElementById("review-rating");
|
||||
const ratingError = document.getElementById("rating-error");
|
||||
const reviewText = document.getElementById("review-text");
|
||||
const charCount = document.getElementById("char-count");
|
||||
const submitBtn = document.getElementById("submit-review-btn");
|
||||
|
||||
// Поля для валидации
|
||||
const roleInput = document.getElementById("review-role");
|
||||
const caseTypeSelect = document.getElementById("review-case-type");
|
||||
const roleError = document.getElementById("role-error");
|
||||
const caseTypeError = document.getElementById("caseType-error");
|
||||
const textError = document.getElementById("text-error");
|
||||
|
||||
const MAX_CHARS = 1000;
|
||||
const MIN_ROLE_LENGTH = 2;
|
||||
const MAX_ROLE_LENGTH = 50;
|
||||
const MIN_TEXT_LENGTH = 10;
|
||||
|
||||
// Проверка авторизации
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
// Пользователь авторизован - показываем форму
|
||||
authRequiredBlock?.classList.add('hidden');
|
||||
reviewFormBlock?.classList.remove('hidden');
|
||||
loadUserName();
|
||||
} else {
|
||||
// Пользователь не авторизован - показываем блок авторизации
|
||||
authRequiredBlock?.classList.remove('hidden');
|
||||
reviewFormBlock?.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthorizedReviewModal] Ошибка проверки авторизации:', error);
|
||||
authRequiredBlock?.classList.remove('hidden');
|
||||
reviewFormBlock?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка имени пользователя
|
||||
const loadUserName = async () => {
|
||||
const userNameDisplay = document.getElementById("user-name-display");
|
||||
if (!userNameDisplay) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated && data.user) {
|
||||
const name = data.user.name || data.user.email?.split('@')[0] || "Пользователь";
|
||||
userNameDisplay.textContent = name;
|
||||
} else {
|
||||
userNameDisplay.textContent = "Пользователь";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AuthorizedReviewModal] Ошибка загрузки имени:", error);
|
||||
userNameDisplay.textContent = "Пользователь";
|
||||
}
|
||||
};
|
||||
|
||||
// Открытие модального окна
|
||||
document.addEventListener("open-review-modal", () => {
|
||||
if (reviewModal) {
|
||||
reviewModal.classList.remove("hidden");
|
||||
reviewModal.classList.add("flex");
|
||||
document.body.style.overflow = "hidden";
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие модального окна
|
||||
const closeModal = () => {
|
||||
if (reviewModal) {
|
||||
reviewModal.classList.add("hidden");
|
||||
reviewModal.classList.remove("flex");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
closeReviewModalBtn?.addEventListener("click", closeModal);
|
||||
|
||||
// Закрытие по клику вне окна
|
||||
reviewModal?.addEventListener("click", (e) => {
|
||||
if (e.target === reviewModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по Escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && reviewModal && !reviewModal.classList.contains("hidden")) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const ratingHint = document.getElementById("rating-hint");
|
||||
|
||||
// Выбор рейтинга
|
||||
starBtns.forEach((btn, index) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const rating = index + 1;
|
||||
ratingInput.value = rating.toString();
|
||||
ratingError?.classList.add("hidden");
|
||||
|
||||
// Скрываем подсказку после выбора
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
|
||||
starBtns.forEach((b, i) => {
|
||||
const svg = b.querySelector("svg");
|
||||
if (i < rating) {
|
||||
b.classList.add("active");
|
||||
svg?.classList.remove("text-[var(--color-gray-600)]");
|
||||
svg?.classList.add("text-[var(--color-gold)]");
|
||||
} else {
|
||||
b.classList.remove("active");
|
||||
svg?.classList.add("text-[var(--color-gray-600)]");
|
||||
svg?.classList.remove("text-[var(--color-gold)]");
|
||||
}
|
||||
});
|
||||
|
||||
validateForm();
|
||||
});
|
||||
});
|
||||
|
||||
// Показываем подсказку при наведении на звёзды, если рейтинг не выбран
|
||||
const ratingContainer = document.getElementById("rating-stars");
|
||||
ratingContainer?.addEventListener("mouseenter", () => {
|
||||
if (ratingInput.value === "0" && ratingHint) {
|
||||
setTimeout(() => ratingHint.classList.remove("opacity-0"), 500);
|
||||
}
|
||||
});
|
||||
|
||||
ratingContainer?.addEventListener("mouseleave", () => {
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
});
|
||||
|
||||
// Показываем подсказку при фокусе на любом поле, если рейтинг не выбран
|
||||
[roleInput, caseTypeSelect, reviewText].forEach(input => {
|
||||
input?.addEventListener("focus", () => {
|
||||
if (ratingInput.value === "0" && ratingHint) {
|
||||
setTimeout(() => ratingHint.classList.remove("opacity-0"), 500);
|
||||
}
|
||||
});
|
||||
input?.addEventListener("blur", () => {
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Подсчёт символов и валидация текста
|
||||
reviewText?.addEventListener("input", () => {
|
||||
const length = reviewText.value.length;
|
||||
if (charCount) {
|
||||
charCount.textContent = `${length} / ${MAX_CHARS}`;
|
||||
if (length > MAX_CHARS) {
|
||||
charCount.classList.add("text-red-500");
|
||||
charCount.classList.remove("text-[var(--color-gray-600)]");
|
||||
} else {
|
||||
charCount.classList.add("text-[var(--color-gray-600)]");
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
}
|
||||
validateTextField();
|
||||
validateForm();
|
||||
});
|
||||
|
||||
reviewText?.addEventListener("blur", () => {
|
||||
validateTextField();
|
||||
});
|
||||
|
||||
// Валидация должности при потере фокуса
|
||||
roleInput?.addEventListener("blur", () => {
|
||||
validateRoleField();
|
||||
});
|
||||
|
||||
// Валидация должности при вводе (убираем ошибку)
|
||||
roleInput?.addEventListener("input", () => {
|
||||
if (roleInput.value.trim().length >= MIN_ROLE_LENGTH) {
|
||||
clearError(roleInput, roleError);
|
||||
}
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// Валидация категории при изменении
|
||||
caseTypeSelect?.addEventListener("change", () => {
|
||||
validateCaseTypeField();
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// Валидация должности
|
||||
const validateRoleField = () => {
|
||||
if (!roleInput) return false;
|
||||
|
||||
const value = roleInput.value.trim();
|
||||
|
||||
if (!value) {
|
||||
showError(roleInput, roleError, "Укажите вашу должность или профессию");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length < MIN_ROLE_LENGTH) {
|
||||
showError(roleInput, roleError, `Должность должна содержать минимум ${MIN_ROLE_LENGTH} символа`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length > MAX_ROLE_LENGTH) {
|
||||
showError(roleInput, roleError, `Должность не должна превышать ${MAX_ROLE_LENGTH} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(roleInput, roleError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Валидация категории дела
|
||||
const validateCaseTypeField = () => {
|
||||
if (!caseTypeSelect) return false;
|
||||
|
||||
const value = caseTypeSelect.value;
|
||||
|
||||
if (!value) {
|
||||
showError(caseTypeSelect, caseTypeError, "Выберите категорию дела");
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(caseTypeSelect, caseTypeError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Валидация текста отзыва
|
||||
const validateTextField = () => {
|
||||
if (!reviewText) return false;
|
||||
|
||||
const value = reviewText.value.trim();
|
||||
|
||||
if (!value) {
|
||||
showError(reviewText, textError, "Введите текст отзыва");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length < MIN_TEXT_LENGTH) {
|
||||
showError(reviewText, textError, `Текст должен содержать минимум ${MIN_TEXT_LENGTH} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length > MAX_CHARS) {
|
||||
showError(reviewText, textError, `Текст не должен превышать ${MAX_CHARS} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(reviewText, textError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Показать ошибку
|
||||
const showError = (input, errorElement, message) => {
|
||||
input.classList.add("border-red-500");
|
||||
input.classList.remove("border-[var(--color-gray-600)]/20");
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
errorElement.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Убрать ошибку
|
||||
const clearError = (input, errorElement) => {
|
||||
input.classList.remove("border-red-500");
|
||||
input.classList.add("border-[var(--color-gray-600)]/20");
|
||||
if (errorElement) {
|
||||
errorElement.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Общая валидация формы и активация кнопки
|
||||
const validateForm = () => {
|
||||
if (!submitBtn) return;
|
||||
|
||||
const isRoleValid = roleInput && roleInput.value.trim().length >= MIN_ROLE_LENGTH && roleInput.value.trim().length <= MAX_ROLE_LENGTH;
|
||||
const isCaseTypeValid = caseTypeSelect && caseTypeSelect.value !== "";
|
||||
const isRatingValid = ratingInput.value && ratingInput.value !== "0";
|
||||
const isTextValid = reviewText && reviewText.value.trim().length >= MIN_TEXT_LENGTH && reviewText.value.length <= MAX_CHARS;
|
||||
|
||||
const isValid = isRoleValid && isCaseTypeValid && isRatingValid && isTextValid;
|
||||
|
||||
if (isValid) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.remove("bg-[var(--color-gray-600)", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.add("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
} else {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add("bg-[var(--color-gray-600)]", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.remove("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
}
|
||||
};
|
||||
|
||||
// Отправка формы
|
||||
reviewForm?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Финальная проверка всех полей
|
||||
const isRoleValid = validateRoleField();
|
||||
const isCaseTypeValid = validateCaseTypeField();
|
||||
const isTextValid = validateTextField();
|
||||
|
||||
// Проверка рейтинга
|
||||
if (!ratingInput.value || ratingInput.value === "0") {
|
||||
ratingError?.classList.remove("hidden");
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Выберите оценку (звёзды)", "error", 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRoleValid || !isCaseTypeValid || !isTextValid) {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Проверьте правильность заполнения полей", "error", 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = submitBtn?.textContent;
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Отправка...";
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData(reviewForm);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
console.log("[Review Modal] Отправка отзыва:", data);
|
||||
|
||||
// Отправка на сервер
|
||||
const response = await fetch('/api/reviews', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log("[Review Modal] Ответ сервера:", result);
|
||||
|
||||
if (!response.ok) {
|
||||
// Ошибка сервера
|
||||
if (response.status === 401) {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Требуется авторизация", "error", 4000);
|
||||
}
|
||||
} else {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(result.error || "Ошибка при создании отзыва", "error", 4000);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешная отправка
|
||||
console.log("[Review Modal] Отзыв успешно отправлен");
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Спасибо за ваш отзыв!", "success", 4000);
|
||||
}
|
||||
|
||||
// Закрываем модальное окно и обновляем страницу
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
// Обновляем страницу, чтобы кнопка заблокировалась
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Review Modal] Ошибка отправки:", error);
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Ошибка при отправке отзыва. Попробуйте позже.", "error", 4000);
|
||||
}
|
||||
// При ошибке восстанавливаем кнопку
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Сброс формы
|
||||
const resetForm = () => {
|
||||
reviewForm?.reset();
|
||||
ratingInput.value = "0";
|
||||
starBtns.forEach((btn) => {
|
||||
btn.classList.remove("active");
|
||||
const svg = btn.querySelector("svg");
|
||||
svg?.classList.add("text-[var(--color-gray-600)]");
|
||||
svg?.classList.remove("text-[var(--color-gold)]");
|
||||
});
|
||||
|
||||
// Сброс ошибок
|
||||
[roleInput, caseTypeSelect, reviewText].forEach(input => {
|
||||
if (input) {
|
||||
input.classList.remove("border-red-500");
|
||||
input.classList.add("border-[var(--color-gray-600)]/20");
|
||||
}
|
||||
});
|
||||
|
||||
[roleError, caseTypeError, textError, ratingError].forEach(error => {
|
||||
if (error) {
|
||||
error.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (charCount) {
|
||||
charCount.textContent = "0 / 1000";
|
||||
charCount.classList.add("text-[var(--color-gray-600)]");
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
|
||||
// Блокировка кнопки отправки
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add("bg-[var(--color-gray-600)]", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.remove("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем redirectUrl к ссылкам авторизации
|
||||
const addRedirectToAuthLinks = () => {
|
||||
const currentPath = window.location.pathname;
|
||||
const loginLink = document.getElementById("login-link");
|
||||
const registerLink = document.getElementById("register-link");
|
||||
const loginLinkInline = document.getElementById("login-link-inline");
|
||||
|
||||
if (loginLink) {
|
||||
loginLink.href = `/auth/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
if (registerLink) {
|
||||
registerLink.href = `/auth/register?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
if (loginLinkInline) {
|
||||
loginLinkInline.href = `/auth/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Инициализация
|
||||
addRedirectToAuthLinks();
|
||||
</script>
|
||||
286
frontend/src/components/reviews/ReviewCard.astro
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
---
|
||||
export interface Review {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar?: string;
|
||||
rating: number;
|
||||
date: string;
|
||||
text: string;
|
||||
caseType: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
review: Review;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const { review, category } = Astro.props;
|
||||
|
||||
// Функция генерации цвета аватарки по имени
|
||||
function getAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
"rgba(59, 130, 246, 0.8)",
|
||||
"rgba(16, 185, 129, 0.8)",
|
||||
"rgba(245, 158, 11, 0.8)",
|
||||
"rgba(239, 68, 68, 0.8)",
|
||||
"rgba(139, 92, 246, 0.8)",
|
||||
"rgba(236, 72, 153, 0.8)",
|
||||
"rgba(14, 165, 233, 0.8)",
|
||||
"rgba(34, 197, 94, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(99, 102, 241, 0.8)",
|
||||
];
|
||||
|
||||
// Генерируем индекс на основе имени
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const index = Math.abs(hash) % colors.length;
|
||||
return colors[index];
|
||||
}
|
||||
---
|
||||
|
||||
<div data-category={review.caseType} class="review-card group">
|
||||
<!-- Внутренний контейнер для изоляции стилей -->
|
||||
<div class="card-content">
|
||||
<!-- Шапка -->
|
||||
<div class="flex items-start gap-4 mb-5">
|
||||
<div
|
||||
class="avatar-wrapper"
|
||||
style="background-color: {getAvatarColor(review.name)};"
|
||||
>
|
||||
{review.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="name">{review.name}</h3>
|
||||
<p class="role">{review.role}</p>
|
||||
</div>
|
||||
|
||||
<!-- Бейдж "Проверено" всегда показываем, т.к. отзывы публикуются сразу -->
|
||||
<div class="verified-badge" title="Отзыв проверен">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Проверено</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Рейтинг -->
|
||||
<div class="flex items-center gap-1 mt-2">
|
||||
{
|
||||
[...Array(5)].map((_, i) => (
|
||||
<svg
|
||||
class={`w-4 h-4 transition-colors duration-300 ${i < review.rating ? "text-[var(--color-gold)]" : "text-[var(--color-gray-600)]"}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Тип дела -->
|
||||
<div class="mb-4">
|
||||
<span class="case-tag">
|
||||
{review.caseType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Текст -->
|
||||
<blockquote class="review-text">
|
||||
{review.text}
|
||||
</blockquote>
|
||||
|
||||
<!-- Футер -->
|
||||
<div class="card-footer">
|
||||
<span class="date">{review.date}</span>
|
||||
<div class="status">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span>Дело выиграно</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 1. Основной контейнер карточки */
|
||||
.review-card {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 0.5s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Внутренняя обертка с фоном и границами */
|
||||
.card-content {
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
background-color: var(--color-navy-dark);
|
||||
border: 1px solid rgba(75, 85, 99, 0.2);
|
||||
border-radius: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.4s ease,
|
||||
box-shadow 0.4s ease,
|
||||
background-color 0.4s ease;
|
||||
}
|
||||
|
||||
/* Эффект при наведении на ВСЮ карточку */
|
||||
.review-card:hover .card-content {
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
background-color: rgba(255, 255, 255, 0.01);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px 0px rgba(212, 175, 55, 0.05);
|
||||
}
|
||||
|
||||
.review-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
|
||||
/* 2. Аватарка */
|
||||
.avatar-wrapper {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(75, 85, 99, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-white);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.review-card:hover .avatar-wrapper {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
border-color: rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
/* 3. Текстовые стили */
|
||||
.name {
|
||||
font-weight: 700;
|
||||
color: var(--color-white);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.role {
|
||||
color: var(--color-gray-500);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.case-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 9999px;
|
||||
color: var(--color-gold);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.review-text {
|
||||
color: var(--color-gray-400);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.625;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 4. Бейдж проверки */
|
||||
.verified-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 9999px;
|
||||
color: var(--color-emerald-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.verified-badge span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.verified-badge span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 5. Футер */
|
||||
.card-footer {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(75, 85, 99, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-emerald-400);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 6. Состояния для фильтрации (JS) */
|
||||
.review-card.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.review-card.opacity-0 {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
619
frontend/src/components/reviews/ReviewModal.astro
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
---
|
||||
import Toast from "@components/base/Toast.astro";
|
||||
---
|
||||
|
||||
<!-- Модальное окно для написания отзыва -->
|
||||
<div
|
||||
id="review-modal"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] hidden items-center justify-center p-4 overflow-y-auto"
|
||||
>
|
||||
<div class="bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-2xl p-8 max-w-2xl w-full relative shadow-2xl animate-fade-in my-8">
|
||||
<!-- Кнопка закрытия -->
|
||||
<button
|
||||
id="close-review-modal"
|
||||
class="absolute top-4 right-4 w-8 h-8 rounded-full bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 hover:border-[var(--color-gold)]/30 flex items-center justify-center transition-all cursor-pointer"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[var(--color-gray-400)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-[var(--color-white)] mb-2">
|
||||
Написать отзыв
|
||||
</h3>
|
||||
<p class="text-[var(--color-gray-400)] text-sm">
|
||||
Поделитесь своим опытом работы с нами. Ваш отзыв поможет другим людям сделать правильный выбор.
|
||||
</p>
|
||||
<!-- Приветствие пользователя -->
|
||||
<div id="user-greeting" class="mt-4 p-4 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl">
|
||||
<p class="text-[var(--color-white)] font-medium">
|
||||
Привет, <span id="user-name-display" class="text-[var(--color-gold)]">...</span>!
|
||||
</p>
|
||||
<p class="text-[var(--color-gray-400)] text-sm mt-1">
|
||||
Вы решили написать отзыв?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<form id="review-form" class="space-y-5" novalidate>
|
||||
<!-- Должность/Профессия -->
|
||||
<div>
|
||||
<label for="review-role" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Должность <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="review-role"
|
||||
name="role"
|
||||
placeholder="Например: Предприниматель, Врач, Учитель"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
pattern="^[а-яА-ЯёЁa-zA-Z\s\-]+$"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium placeholder-[var(--color-gray-600)] focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
|
||||
/>
|
||||
<span id="role-error" class="text-red-500 text-xs mt-1 hidden"></span>
|
||||
</div>
|
||||
|
||||
<!-- Категория дела -->
|
||||
<div>
|
||||
<label for="review-case-type" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Категория дела <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="review-case-type"
|
||||
name="caseType"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="" disabled selected>Выберите категорию</option>
|
||||
<option value="Семейные дела">Семейные дела</option>
|
||||
<option value="Административные дела">Административные дела</option>
|
||||
<option value="Уголовные дела">Уголовные дела</option>
|
||||
<option value="Гражданские дела">Гражданские дела</option>
|
||||
<option value="Арбитражные дела">Арбитражные дела</option>
|
||||
<option value="Защита должников">Защита должников</option>
|
||||
<option value="Дела СВО">Дела СВО</option>
|
||||
</select>
|
||||
<span id="caseType-error" class="text-red-500 text-xs mt-1 hidden"></span>
|
||||
</div>
|
||||
|
||||
<!-- Рейтинг -->
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Оценка <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 items-center" id="rating-stars">
|
||||
<button type="button" data-rating="1" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 1 звезду">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="2" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 2 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="3" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 3 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="4" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 4 звезды">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" data-rating="5" class="star-btn text-3xl transition-transform hover:scale-110 cursor-pointer group relative" aria-label="Поставить 5 звезд">
|
||||
<svg class="w-8 h-8 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Подсказка если рейтинг не выбран -->
|
||||
<span id="rating-hint" class="text-xs text-[var(--color-gold)] ml-2 opacity-0 transition-opacity duration-300">
|
||||
⬅️ Выберите оценку
|
||||
</span>
|
||||
</div>
|
||||
<input type="hidden" id="review-rating" name="rating" value="0" />
|
||||
<span id="rating-error" class="text-red-500 text-xs mt-1 hidden">Поставьте оценку</span>
|
||||
</div>
|
||||
|
||||
<!-- Текст отзыва -->
|
||||
<div>
|
||||
<label for="review-text" class="block text-xs font-bold text-[var(--color-gray-500)] uppercase tracking-wider mb-2">
|
||||
Текст отзыва <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-text"
|
||||
name="text"
|
||||
placeholder="Расскажите о вашем опыте работы с нами..."
|
||||
required
|
||||
minlength="10"
|
||||
rows="5"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] font-medium placeholder-[var(--color-gray-600)] focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all resize-none"
|
||||
></textarea>
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span id="text-error" class="text-red-500 text-xs hidden"></span>
|
||||
<span id="char-count" class="text-xs text-[var(--color-gray-600)]">0 / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-review-btn"
|
||||
disabled
|
||||
class="w-full py-3 bg-[var(--color-gray-600)] text-[var(--color-gray-400)] font-bold rounded-xl transition-all cursor-not-allowed opacity-50"
|
||||
data-enabled-class="bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] hover:shadow-lg hover:shadow-[var(--color-gold)]/30 hover:cursor-pointer"
|
||||
>
|
||||
Отправить отзыв
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast компонент -->
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
#rating-stars .star-btn.active svg {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Убираем стандартную стрелку у select */
|
||||
#review-case-type {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const reviewModal = document.getElementById("review-modal");
|
||||
const closeReviewModalBtn = document.getElementById("close-review-modal");
|
||||
const reviewForm = document.getElementById("review-form");
|
||||
const starBtns = document.querySelectorAll(".star-btn");
|
||||
const ratingInput = document.getElementById("review-rating");
|
||||
const ratingError = document.getElementById("rating-error");
|
||||
const reviewText = document.getElementById("review-text");
|
||||
const charCount = document.getElementById("char-count");
|
||||
const submitBtn = document.getElementById("submit-review-btn");
|
||||
|
||||
// Поля для валидации
|
||||
const roleInput = document.getElementById("review-role");
|
||||
const caseTypeSelect = document.getElementById("review-case-type");
|
||||
const roleError = document.getElementById("role-error");
|
||||
const caseTypeError = document.getElementById("caseType-error");
|
||||
const textError = document.getElementById("text-error");
|
||||
|
||||
const MAX_CHARS = 1000;
|
||||
const MIN_ROLE_LENGTH = 2;
|
||||
const MAX_ROLE_LENGTH = 50;
|
||||
const MIN_TEXT_LENGTH = 10;
|
||||
|
||||
// Открытие модального окна
|
||||
document.addEventListener("open-review-modal", () => {
|
||||
if (reviewModal) {
|
||||
reviewModal.classList.remove("hidden");
|
||||
reviewModal.classList.add("flex");
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Загружаем имя пользователя для приветствия
|
||||
loadUserName();
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка имени пользователя
|
||||
const loadUserName = async () => {
|
||||
const userNameDisplay = document.getElementById("user-name-display");
|
||||
if (!userNameDisplay) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated && data.user) {
|
||||
const name = data.user.name || data.user.email?.split('@')[0] || "Пользователь";
|
||||
userNameDisplay.textContent = name;
|
||||
} else {
|
||||
userNameDisplay.textContent = "Пользователь";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ReviewModal] Ошибка загрузки имени:", error);
|
||||
userNameDisplay.textContent = "Пользователь";
|
||||
}
|
||||
};
|
||||
|
||||
// Закрытие модального окна
|
||||
const closeModal = () => {
|
||||
if (reviewModal) {
|
||||
reviewModal.classList.add("hidden");
|
||||
reviewModal.classList.remove("flex");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
closeReviewModalBtn?.addEventListener("click", closeModal);
|
||||
|
||||
// Закрытие по клику вне окна
|
||||
reviewModal?.addEventListener("click", (e) => {
|
||||
if (e.target === reviewModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по Escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && reviewModal && !reviewModal.classList.contains("hidden")) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const ratingHint = document.getElementById("rating-hint");
|
||||
|
||||
// Выбор рейтинга
|
||||
starBtns.forEach((btn, index) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const rating = index + 1;
|
||||
ratingInput.value = rating.toString();
|
||||
ratingError?.classList.add("hidden");
|
||||
|
||||
// Скрываем подсказку после выбора
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
|
||||
starBtns.forEach((b, i) => {
|
||||
const svg = b.querySelector("svg");
|
||||
if (i < rating) {
|
||||
b.classList.add("active");
|
||||
svg?.classList.remove("text-[var(--color-gray-600)]");
|
||||
svg?.classList.add("text-[var(--color-gold)]");
|
||||
} else {
|
||||
b.classList.remove("active");
|
||||
svg?.classList.add("text-[var(--color-gray-600)]");
|
||||
svg?.classList.remove("text-[var(--color-gold)]");
|
||||
}
|
||||
});
|
||||
|
||||
validateForm();
|
||||
});
|
||||
});
|
||||
|
||||
// Показываем подсказку при наведении на звёзды, если рейтинг не выбран
|
||||
const ratingContainer = document.getElementById("rating-stars");
|
||||
ratingContainer?.addEventListener("mouseenter", () => {
|
||||
if (ratingInput.value === "0" && ratingHint) {
|
||||
ratingHint.classList.remove("opacity-0");
|
||||
}
|
||||
});
|
||||
|
||||
ratingContainer?.addEventListener("mouseleave", () => {
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
});
|
||||
|
||||
// Показываем подсказку при фокусе на любом поле, если рейтинг не выбран
|
||||
[roleInput, caseTypeSelect, reviewText].forEach(input => {
|
||||
input?.addEventListener("focus", () => {
|
||||
if (ratingInput.value === "0" && ratingHint) {
|
||||
setTimeout(() => ratingHint.classList.remove("opacity-0"), 500);
|
||||
}
|
||||
});
|
||||
input?.addEventListener("blur", () => {
|
||||
if (ratingHint) {
|
||||
ratingHint.classList.add("opacity-0");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Подсчёт символов и валидация текста
|
||||
reviewText?.addEventListener("input", () => {
|
||||
const length = reviewText.value.length;
|
||||
if (charCount) {
|
||||
charCount.textContent = `${length} / ${MAX_CHARS}`;
|
||||
if (length > MAX_CHARS) {
|
||||
charCount.classList.add("text-red-500");
|
||||
charCount.classList.remove("text-[var(--color-gray-600)]");
|
||||
} else {
|
||||
charCount.classList.add("text-[var(--color-gray-600)]");
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
}
|
||||
validateTextField();
|
||||
validateForm();
|
||||
});
|
||||
|
||||
reviewText?.addEventListener("blur", () => {
|
||||
validateTextField();
|
||||
});
|
||||
|
||||
// Валидация должности при потере фокуса
|
||||
roleInput?.addEventListener("blur", () => {
|
||||
validateRoleField();
|
||||
});
|
||||
|
||||
// Валидация должности при вводе (убираем ошибку)
|
||||
roleInput?.addEventListener("input", () => {
|
||||
if (roleInput.value.trim().length >= MIN_ROLE_LENGTH) {
|
||||
clearError(roleInput, roleError);
|
||||
}
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// Валидация категории при изменении
|
||||
caseTypeSelect?.addEventListener("change", () => {
|
||||
validateCaseTypeField();
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// Валидация должности
|
||||
const validateRoleField = () => {
|
||||
if (!roleInput) return false;
|
||||
|
||||
const value = roleInput.value.trim();
|
||||
|
||||
if (!value) {
|
||||
showError(roleInput, roleError, "Укажите вашу должность или профессию");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length < MIN_ROLE_LENGTH) {
|
||||
showError(roleInput, roleError, `Должность должна содержать минимум ${MIN_ROLE_LENGTH} символа`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length > MAX_ROLE_LENGTH) {
|
||||
showError(roleInput, roleError, `Должность не должна превышать ${MAX_ROLE_LENGTH} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(roleInput, roleError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Валидация категории дела
|
||||
const validateCaseTypeField = () => {
|
||||
if (!caseTypeSelect) return false;
|
||||
|
||||
const value = caseTypeSelect.value;
|
||||
|
||||
if (!value) {
|
||||
showError(caseTypeSelect, caseTypeError, "Выберите категорию дела");
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(caseTypeSelect, caseTypeError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Валидация текста отзыва
|
||||
const validateTextField = () => {
|
||||
if (!reviewText) return false;
|
||||
|
||||
const value = reviewText.value.trim();
|
||||
|
||||
if (!value) {
|
||||
showError(reviewText, textError, "Введите текст отзыва");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length < MIN_TEXT_LENGTH) {
|
||||
showError(reviewText, textError, `Текст должен содержать минимум ${MIN_TEXT_LENGTH} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length > MAX_CHARS) {
|
||||
showError(reviewText, textError, `Текст не должен превышать ${MAX_CHARS} символов`);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearError(reviewText, textError);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Показать ошибку
|
||||
const showError = (input, errorElement, message) => {
|
||||
input.classList.add("border-red-500");
|
||||
input.classList.remove("border-[var(--color-gray-600)]/20");
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
errorElement.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Убрать ошибку
|
||||
const clearError = (input, errorElement) => {
|
||||
input.classList.remove("border-red-500");
|
||||
input.classList.add("border-[var(--color-gray-600)]/20");
|
||||
if (errorElement) {
|
||||
errorElement.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Общая валидация формы и активация кнопки
|
||||
const validateForm = () => {
|
||||
if (!submitBtn) return;
|
||||
|
||||
const isRoleValid = roleInput && roleInput.value.trim().length >= MIN_ROLE_LENGTH && roleInput.value.trim().length <= MAX_ROLE_LENGTH;
|
||||
const isCaseTypeValid = caseTypeSelect && caseTypeSelect.value !== "";
|
||||
const isRatingValid = ratingInput.value && ratingInput.value !== "0";
|
||||
const isTextValid = reviewText && reviewText.value.trim().length >= MIN_TEXT_LENGTH && reviewText.value.length <= MAX_CHARS;
|
||||
|
||||
const isValid = isRoleValid && isCaseTypeValid && isRatingValid && isTextValid;
|
||||
|
||||
if (isValid) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.remove("bg-[var(--color-gray-600)", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.add("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
} else {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add("bg-[var(--color-gray-600)]", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.remove("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
}
|
||||
};
|
||||
|
||||
// Отправка формы
|
||||
reviewForm?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Финальная проверка всех полей
|
||||
const isRoleValid = validateRoleField();
|
||||
const isCaseTypeValid = validateCaseTypeField();
|
||||
const isTextValid = validateTextField();
|
||||
|
||||
// Проверка рейтинга
|
||||
if (!ratingInput.value || ratingInput.value === "0") {
|
||||
ratingError?.classList.remove("hidden");
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Выберите оценку (звёзды)", "error", 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRoleValid || !isCaseTypeValid || !isTextValid) {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Проверьте правильность заполнения полей", "error", 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = submitBtn?.textContent;
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Отправка...";
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData(reviewForm);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
console.log("[Review Modal] Отправка отзыва:", data);
|
||||
|
||||
// Отправка на сервер
|
||||
const response = await fetch('/api/reviews', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log("[Review Modal] Ответ сервера:", result);
|
||||
|
||||
if (!response.ok) {
|
||||
// Ошибка сервера
|
||||
if (response.status === 401) {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Требуется авторизация", "error", 4000);
|
||||
}
|
||||
} else {
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast(result.error || "Ошибка при создании отзыва", "error", 4000);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешная отправка
|
||||
console.log("[Review Modal] Отзыв успешно отправлен");
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Спасибо за ваш отзыв!", "success", 4000);
|
||||
}
|
||||
|
||||
// Закрываем модальное окно и обновляем страницу
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
// Обновляем страницу, чтобы кнопка заблокировалась
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Review Modal] Ошибка отправки:", error);
|
||||
if (typeof window.showToast === "function") {
|
||||
window.showToast("Ошибка при отправке отзыва. Попробуйте позже.", "error", 4000);
|
||||
}
|
||||
// При ошибке восстанавливаем кнопку
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Сброс формы
|
||||
const resetForm = () => {
|
||||
reviewForm?.reset();
|
||||
ratingInput.value = "0";
|
||||
starBtns.forEach((btn) => {
|
||||
btn.classList.remove("active");
|
||||
const svg = btn.querySelector("svg");
|
||||
svg?.classList.add("text-[var(--color-gray-600)]");
|
||||
svg?.classList.remove("text-[var(--color-gold)]");
|
||||
});
|
||||
|
||||
// Сброс ошибок
|
||||
[roleInput, caseTypeSelect, reviewText].forEach(input => {
|
||||
if (input) {
|
||||
input.classList.remove("border-red-500");
|
||||
input.classList.add("border-[var(--color-gray-600)]/20");
|
||||
}
|
||||
});
|
||||
|
||||
[roleError, caseTypeError, textError, ratingError].forEach(error => {
|
||||
if (error) {
|
||||
error.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
if (charCount) {
|
||||
charCount.textContent = "0 / 1000";
|
||||
charCount.classList.add("text-[var(--color-gray-600)]");
|
||||
charCount.classList.remove("text-red-500");
|
||||
}
|
||||
|
||||
// Блокировка кнопки отправки
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add("bg-[var(--color-gray-600)]", "text-[var(--color-gray-400)]", "cursor-not-allowed", "opacity-50");
|
||||
submitBtn.classList.remove("bg-[var(--color-gold)]", "hover:bg-[var(--color-gold-hover)]", "text-[var(--color-white)]", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30", "hover:cursor-pointer");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
122
frontend/src/components/reviews/ReviewsCTA.astro
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
---
|
||||
|
||||
<!-- Призыв к действию -->
|
||||
<div class="relative bg-gradient-to-br from-[var(--color-gold)]/20 to-[var(--color-navy-dark)] border border-[var(--color-gold)]/30 rounded-3xl p-8 md:p-12 overflow-hidden mb-16">
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-20 rounded-full blur-3xl translate-x-1/2 -translate-y-1/2"></div>
|
||||
|
||||
<div class="relative flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div class="text-center md:text-left">
|
||||
<!-- Блок для авторизованного пользователя -->
|
||||
<div id="auth-logged-in" class="hidden">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-[var(--color-white)] mb-3">
|
||||
Хотите поделиться своим опытом?
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-400)] max-w-md">
|
||||
Ваш отзыв поможет другим людям сделать правильный выбор. Мы ценим каждое мнение.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Блок для неавторизованного пользователя -->
|
||||
<div id="auth-logged-out">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-[var(--color-white)] mb-3">
|
||||
Войдите, чтобы оставить отзыв
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-400)] max-w-md">
|
||||
Авторизуйтесь, чтобы поделиться своим опытом и помочь другим людям сделать правильный выбор.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Кнопки для авторизованного пользователя -->
|
||||
<div id="auth-buttons-logged-in" class="hidden flex flex-col sm:flex-row gap-4">
|
||||
<button id="open-review-modal-btn" class="group px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-xl transition-all hover:shadow-lg hover:shadow-[var(--color-gold)]/30 hover:-translate-y-1 flex items-center justify-center gap-2 hover:cursor-pointer">
|
||||
<span class="text-xl">✍️</span>
|
||||
Написать отзыв
|
||||
</button>
|
||||
</div>
|
||||
<!-- Кнопка для неавторизованного пользователя -->
|
||||
<div id="auth-buttons-logged-out" class="flex flex-col sm:flex-row gap-4">
|
||||
<a
|
||||
id="login-link"
|
||||
href="/auth/login"
|
||||
class="group px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-xl transition-all hover:shadow-lg hover:shadow-[var(--color-gold)]/30 hover:-translate-y-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span class="text-xl">🔐</span>
|
||||
Войти
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Проверка авторизации и переключение блоков
|
||||
const checkAuth = async () => {
|
||||
const authLoggedInBlock = document.getElementById("auth-logged-in");
|
||||
const authLoggedOutBlock = document.getElementById("auth-logged-out");
|
||||
const authButtonsLoggedIn = document.getElementById("auth-buttons-logged-in");
|
||||
const authButtonsLoggedOut = document.getElementById("auth-buttons-logged-out");
|
||||
const openReviewBtn = document.getElementById("open-review-modal-btn");
|
||||
|
||||
if (!authLoggedInBlock || !authLoggedOutBlock || !authButtonsLoggedIn || !authButtonsLoggedOut) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
// Пользователь авторизован - показываем блоки для logged-in
|
||||
authLoggedInBlock.classList.remove("hidden");
|
||||
authButtonsLoggedIn.classList.remove("hidden");
|
||||
authLoggedOutBlock.classList.add("hidden");
|
||||
authButtonsLoggedOut.classList.add("hidden");
|
||||
|
||||
// Проверяем через API, есть ли у пользователя уже отзыв
|
||||
if (openReviewBtn) {
|
||||
const checkResponse = await fetch('/api/reviews/check', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const checkData = await checkResponse.json();
|
||||
|
||||
if (checkData.hasReview) {
|
||||
// Блокируем кнопку "Написать отзыв"
|
||||
openReviewBtn.disabled = true;
|
||||
openReviewBtn.classList.add("opacity-50", "cursor-not-allowed", "hover:cursor-not-allowed");
|
||||
openReviewBtn.classList.remove("hover:-translate-y-1", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30");
|
||||
openReviewBtn.innerHTML = '<span class="text-xl">✓</span> Отзыв уже отправлен';
|
||||
console.log('[ReviewsCTA] Кнопка заблокирована - у пользователя уже есть отзыв');
|
||||
} else {
|
||||
// У пользователя нет отзыва - разблокируем кнопку
|
||||
openReviewBtn.disabled = false;
|
||||
openReviewBtn.classList.remove("opacity-50", "cursor-not-allowed", "hover:cursor-not-allowed");
|
||||
openReviewBtn.classList.add("hover:-translate-y-1", "hover:shadow-lg", "hover:shadow-[var(--color-gold)]/30");
|
||||
openReviewBtn.innerHTML = '<span class="text-xl">✍️</span> Написать отзыв';
|
||||
console.log('[ReviewsCTA] Кнопка активна - отзывов нет');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Пользователь не авторизован - показываем блоки для logged-out
|
||||
authLoggedInBlock.classList.add("hidden");
|
||||
authButtonsLoggedIn.classList.add("hidden");
|
||||
authLoggedOutBlock.classList.remove("hidden");
|
||||
authButtonsLoggedOut.classList.remove("hidden");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ReviewsCTA] Ошибка проверки авторизации:', error);
|
||||
// При ошибке показываем блоки для неавторизованного
|
||||
authLoggedInBlock.classList.add("hidden");
|
||||
authButtonsLoggedIn.classList.add("hidden");
|
||||
authLoggedOutBlock.classList.remove("hidden");
|
||||
authButtonsLoggedOut.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
interface PlatformLink {
|
||||
emoji: string;
|
||||
name: string;
|
||||
rating: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const platforms: PlatformLink[] = [
|
||||
{
|
||||
emoji: "🗺️",
|
||||
name: "Яндекс Карты",
|
||||
rating: "4.9",
|
||||
href: "#"
|
||||
},
|
||||
{
|
||||
emoji: "🔍",
|
||||
name: "Google",
|
||||
rating: "5.0",
|
||||
href: "#"
|
||||
},
|
||||
{
|
||||
emoji: "📍",
|
||||
name: "2ГИС",
|
||||
rating: "4.8",
|
||||
href: "#"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<div class="mt-16 text-center">
|
||||
<p class="text-[var(--color-gray-600)] text-sm mb-6">Читайте отзывы на внешних площадках</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
{platforms.map((platform) => (
|
||||
<a
|
||||
href={platform.href || "#"}
|
||||
class="flex items-center gap-2 px-6 py-3 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-gray-400)] hover:border-[var(--color-gold)]/30 hover:text-[var(--color-white)] transition-all"
|
||||
>
|
||||
<span class="text-xl">{platform.emoji}</span>
|
||||
<span class="font-medium">{platform.name}</span>
|
||||
<span class="px-2 py-0.5 bg-[var(--color-gold)]/20 rounded text-[var(--color-gold)] text-xs font-bold">{platform.rating}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
18
frontend/src/components/reviews/ReviewsFilters.astro
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
interface Props {
|
||||
caseTypes: string[];
|
||||
}
|
||||
|
||||
const { caseTypes } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-12" id="case-filters">
|
||||
{caseTypes.map((type, index) => (
|
||||
<button
|
||||
data-filter={type === "Все отзывы" ? "all" : type}
|
||||
class={`filter-btn px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 hover:cursor-pointer ${index === 0 ? 'bg-[var(--color-gold)] text-[var(--color-white)]' : 'bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 text-[var(--color-gray-400)] hover:border-[var(--color-gold)]/30 hover:text-[var(--color-white)]'}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
19
frontend/src/components/reviews/ReviewsGrid.astro
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
import ReviewCard from "./ReviewCard.astro";
|
||||
import type { Review } from "./ReviewCard.astro";
|
||||
|
||||
interface Props {
|
||||
reviews: Array<{
|
||||
review: Review;
|
||||
category: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { reviews } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16" id="reviews-grid">
|
||||
{reviews.map(({ review, category }) => (
|
||||
<ReviewCard review={review} category={category} />
|
||||
))}
|
||||
</div>
|
||||
41
frontend/src/components/reviews/ReviewsPagination.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
// Файл: src/components/reviews/ReviewsPagination.astro
|
||||
// Логика передана клиенту (см. страницу reviews.astro)
|
||||
---
|
||||
|
||||
<!-- Важно: id="pagination-container" -->
|
||||
<div
|
||||
id="pagination-container"
|
||||
class="flex justify-center items-center gap-2 mb-16 hidden"
|
||||
>
|
||||
<!-- Кнопка Назад -->
|
||||
<button
|
||||
id="prev-page-btn"
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 flex items-center justify-center text-[var(--color-gray-400)] hover:border-[var(--color-gold)]/30 hover:text-[var(--color-white)] transition-all hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Контейнер для динамических номеров страниц -->
|
||||
<div id="pagination-numbers" class="flex gap-2"></div>
|
||||
|
||||
<!-- Кнопка Вперед -->
|
||||
<button
|
||||
id="next-page-btn"
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 flex items-center justify-center text-[var(--color-gray-400)] hover:border-[var(--color-gold)]/30 hover:text-[var(--color-white)] transition-all hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
30
frontend/src/components/reviews/ReviewsStats.astro
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
// Файл: src/components/reviews/ReviewsStats.astro
|
||||
export interface StatItem {
|
||||
value: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: StatItem[];
|
||||
}
|
||||
|
||||
const { stats } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div class="group bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-2xl p-6 text-center hover:border-[var(--color-gold)]/30 hover:shadow-lg hover:shadow-[var(--color-gold)]/5 transition-all duration-300">
|
||||
<div class="text-3xl mb-2 group-hover:scale-110 transition-transform">
|
||||
{stat.emoji}
|
||||
</div>
|
||||
<div class="text-3xl md:text-4xl font-black text-[var(--color-gold)] mb-1">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">{stat.label}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
41
frontend/src/components/reviews/ReviewsTrustBlocks.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
interface TrustItem {
|
||||
emoji: string;
|
||||
bgClass: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const trustBlocks: TrustItem[] = [
|
||||
{
|
||||
emoji: "🔒",
|
||||
bgClass: "bg-[var(--color-emerald-500)]/10",
|
||||
title: "Конфиденциальность",
|
||||
description: "Все отзывы публикуются с согласия клиентов. Личные данные защищены."
|
||||
},
|
||||
{
|
||||
emoji: "📋",
|
||||
bgClass: "bg-[var(--color-blue-primary)]/10",
|
||||
title: "Реальные дела",
|
||||
description: "Каждый отзыв подтверждён документально. Мы не публикуем фейки."
|
||||
},
|
||||
{
|
||||
emoji: "⭐",
|
||||
bgClass: "bg-[var(--color-gold)]/10",
|
||||
title: "Независимость",
|
||||
description: "Отзывы с Яндекс.Карт, Google и 2ГИС. Мы не редактируем мнения."
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
|
||||
{trustBlocks.map((block) => (
|
||||
<div class="bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-2xl p-6 text-center hover:border-[var(--color-gold)]/20 transition-all">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl {block.bgClass} flex items-center justify-center text-3xl">
|
||||
{block.emoji}
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-[var(--color-white)] mb-2">{block.title}</h3>
|
||||
<p class="text-sm text-[var(--color-gray-500)]">{block.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
import SocialIcons from "@components/base/SocialIcons.astro";
|
||||
---
|
||||
|
||||
<section
|
||||
class="py-20 px-4 relative overflow-hidden bg-[var(--color-navy-dark)]"
|
||||
>
|
||||
<!-- Фоновый паттерн -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.02]"
|
||||
style="background-image: radial-gradient(circle at 2px 2px, var(--color-gold) 1px, transparent 0); background-size: 32px 32px;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Градиенты -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-[var(--color-navy)] via-[var(--color-navy)]/95 to-[var(--color-navy)]/90"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-[var(--color-gold)]/5 to-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="max-w-6xl mx-auto relative z-10">
|
||||
<div
|
||||
class="relative bg-[var(--color-navy)] border border-[var(--color-gray-600)]/10 rounded-3xl p-8 md:p-12 overflow-hidden shadow-2xl"
|
||||
>
|
||||
<!-- Декоративные элементы -->
|
||||
<div
|
||||
class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-[0.03] rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-48 h-48 bg-[var(--color-blue-primary)] opacity-[0.03] rounded-full blur-2xl translate-y-1/2 -translate-x-1/2"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col lg:flex-row items-center justify-between gap-10"
|
||||
>
|
||||
<!-- Левая часть -->
|
||||
<div class="flex-1 text-center lg:text-left">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-emerald-500)]/10 border border-[var(--color-emerald-500)]/20 rounded-full text-[var(--color-emerald-400)] text-xs font-bold uppercase tracking-wider mb-6"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-emerald-400)] opacity-75"
|
||||
></span>
|
||||
<span
|
||||
class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-emerald-400)]"
|
||||
></span>
|
||||
</span>
|
||||
Срочная помощь 24/7
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-white)] mb-4 leading-tight"
|
||||
>
|
||||
Нужна срочная <span class="text-[var(--color-gold)]"
|
||||
>консультация</span
|
||||
>?
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-[var(--color-gray-400)] text-lg mb-8 max-w-xl mx-auto lg:mx-0"
|
||||
>
|
||||
Административное дело требует немедленной реакции. Каждая минута
|
||||
важна для успешного исхода.
|
||||
</p>
|
||||
|
||||
<!-- Преимущества -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row flex-wrap gap-4 sm:gap-6 items-start justify-start pl-12 sm:pl-16 md:pl-24 lg:pl-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-[var(--color-gray-400)]">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-emerald-500)]/10 border border-[var(--color-emerald-500)]/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-emerald-400)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm">Выезд за 1 час</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[var(--color-gray-400)]">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-emerald-500)]/10 border border-[var(--color-emerald-500)]/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-emerald-400)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm">Первый звонок бесплатно</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[var(--color-gray-400)]">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-[var(--color-emerald-500)]/10 border border-[var(--color-emerald-500)]/20 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-emerald-400)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm">Работаем по всей области</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая часть -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-6 lg:border-l lg:border-[var(--color-gray-600)]/10 lg:pl-12"
|
||||
>
|
||||
<a
|
||||
href={CONTACT_CONSTANTS.phoneHref}
|
||||
class="group relative px-10 py-5 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-black text-lg rounded-2xl shadow-lg shadow-[var(--color-gold)]/20 hover:shadow-[var(--color-gold)]/40 hover:scale-105 transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000"
|
||||
></span>
|
||||
<span class="relative flex items-center gap-3">
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
></path>
|
||||
</svg>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-[var(--color-gray-500)] text-sm mb-3">
|
||||
Или напишите в мессенджер
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<SocialIcons variant="messenger" whatsapp={false} imo={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Консультация",
|
||||
description: "Бесплатный анализ ситуации, изучение документов, оценка перспектив дела и разработка стратегии защиты.",
|
||||
icon: `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>`
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Сбор доказательств",
|
||||
description: "Запрос материалов дела, выявление процессуальных нарушений, подготовка доказательной базы для защиты.",
|
||||
icon: `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>`
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Подготовка жалобы",
|
||||
description: "Составление юридически грамотной жалобы с указанием всех нарушений закона и процессуальных ошибок.",
|
||||
icon: `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>`
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Защита в суде",
|
||||
description: "Профессиональное представление интересов в судебных заседаниях. Добиваемся отмены или смягчения наказания.",
|
||||
icon: `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/></svg>`
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section id="process" class="py-24 bg-[var(--color-navy)] relative overflow-hidden">
|
||||
<!-- Фоновая сетка -->
|
||||
<div class="absolute inset-0 opacity-[0.02]" style="background-image: linear-gradient(rgba(191,155,88,.3) 1px, transparent 1px), linear-gradient(90deg, rgba(191,155,88,.3) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
||||
|
||||
<!-- Градиентные шары -->
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-[var(--color-blue-primary)] opacity-[0.03] rounded-full blur-[100px]"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--color-gold)] opacity-[0.03] rounded-full blur-[100px]"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center mb-20">
|
||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider mb-6">
|
||||
Алгоритм
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-[var(--color-white)] mb-6">
|
||||
Как мы <span class="text-[var(--color-gold)]">работаем</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] max-w-2xl mx-auto text-lg">
|
||||
Четкий план действий от первичной консультации до положительного решения суда
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Процесс -->
|
||||
<div class="relative">
|
||||
<!-- Линия соединения (десктоп) -->
|
||||
<div class="hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-[1px] bg-gradient-to-r from-transparent via-[var(--color-gold)]/20 to-transparent"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{steps.map((step, index) => (
|
||||
<div class="relative group">
|
||||
<!-- Карточка -->
|
||||
<div class="relative bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/10 rounded-2xl p-8 hover:border-[var(--color-gold)]/30 transition-all duration-500 h-full flex flex-col items-center text-center md:items-start md:text-left">
|
||||
<!-- Номер — скрыт на мобильных, виден на md и выше -->
|
||||
<div class="hidden md:flex absolute -top-4 -right-4 w-12 h-12 bg-[var(--color-gold)] rounded-xl items-center justify-center text-[var(--color-navy)] font-black text-lg shadow-lg transform group-hover:scale-110 transition-transform duration-300">
|
||||
{step.number}
|
||||
</div>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="w-14 h-14 rounded-2xl bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 flex items-center justify-center mb-6 text-[var(--color-gold)] group-hover:bg-[var(--color-gold)] group-hover:text-[var(--color-white)] transition-all duration-300">
|
||||
<Fragment set:html={step.icon} />
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-[var(--color-white)] mb-3">{step.title}</h3>
|
||||
<p class="text-[var(--color-gray-500)] text-sm leading-relaxed">{step.description}</p>
|
||||
|
||||
<!-- Прогресс бар -->
|
||||
<div class="mt-6 pt-6 border-t border-[var(--color-gray-600)]/10 w-full">
|
||||
<div class="flex items-center gap-2 justify-center md:justify-start">
|
||||
<div class="flex-1 h-1 bg-[var(--color-gray-600)]/20 rounded-full overflow-hidden max-w-[120px] md:max-w-none">
|
||||
<div class="h-full bg-[var(--color-gold)] rounded-full transform origin-left scale-x-0 group-hover:scale-x-100 transition-transform duration-700 delay-100" style="width: 100%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--color-gray-600)] font-mono">{((index + 1) * 25)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Стрелка -->
|
||||
{index < steps.length - 1 && (
|
||||
<div class="hidden lg:flex absolute top-24 -right-4 transform translate-x-1/2 text-[var(--color-gold)]/20 group-hover:text-[var(--color-gold)]/40 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
import CardServiceGrid, { type Service } from "@components/base/CardServiceGrid.astro";
|
||||
|
||||
const services: Service[] = [
|
||||
{
|
||||
title: "Обжалование штрафов",
|
||||
description: "Оспаривание штрафов с камер видеофиксации и инспекторов ГИБДД. Поиск процессуальных нарушений, снижение или полная отмена штрафов.",
|
||||
emoji: "📝",
|
||||
price: "от 3 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Лишение прав",
|
||||
description: "Защита при лишении водительских прав: алкогольное опьянение, отказ от освидетельствования, выезд на встречную полосу.",
|
||||
emoji: "🚗",
|
||||
price: "от 15 000 ₽",
|
||||
popular: true
|
||||
},
|
||||
{
|
||||
title: "ДТП и административка",
|
||||
description: "Сопровождение при административных протоколах по ДТП. Оспаривание вины, снижение штрафов, защита от лишения прав.",
|
||||
emoji: "💥",
|
||||
price: "от 10 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Миграционные нарушения",
|
||||
description: "Защита при нарушениях миграционного законодательства: регистрация, РВП, ВНЖ, депортация, административное выдворение.",
|
||||
emoji: "⚠️",
|
||||
price: "от 20 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Торговля и КоАП",
|
||||
description: "Защита предпринимателей при проверках. Обжалование штрафов за нарушения в сфере торговли, алкогольной продукции, лицензирования.",
|
||||
emoji: "🏪",
|
||||
price: "от 25 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Представление в суде",
|
||||
description: "Представление интересов в районных и областных судах по административным делам. Подготовка жалоб, ходатайств, апелляций.",
|
||||
emoji: "🏛️",
|
||||
price: "от 15 000 ₽"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<CardServiceGrid
|
||||
services={services}
|
||||
sectionId="services"
|
||||
title={'Услуги по <span class="text-[var(--color-gold)]">административным</span> делам'}
|
||||
subtitle="Практика"
|
||||
description="Комплексная юридическая помощь при привлечении к административной ответственности. Работаю со всеми категориями дел."
|
||||
bgColor="navy-dark"
|
||||
accentColor="gold"
|
||||
/>
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Защита по административным делам",
|
||||
subtitle = "Профессиональная помощь при привлечении к административной ответственности. Обжалование постановлений, защита прав в судах, сопровождение на всех этапах.",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section
|
||||
class="relative min-h-[90vh] flex flex-col justify-center lg:items-center overflow-hidden bg-[var(--color-navy)]"
|
||||
>
|
||||
<!-- Фон -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1589829085413-56de8ae18c73?q=80&w=2000&auto=format&fit=crop"
|
||||
alt="Административные дела"
|
||||
class="w-full h-full object-cover opacity-10 scale-110 animate-slow-zoom"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-[var(--color-navy)] via-[var(--color-navy)]/95 to-[var(--color-navy-dark)]"
|
||||
>
|
||||
</div>
|
||||
<!-- Тонкая сетка -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.03]"
|
||||
style="background-image: radial-gradient(circle at 1px 1px, var(--color-gold) 1px, transparent 0); background-size: 40px 40px;"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
class="absolute top-1/4 right-1/4 w-[500px] h-[500px] bg-[var(--color-gold)] opacity-[0.03] rounded-full blur-[120px]"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 w-[400px] h-[400px] bg-[var(--color-blue-primary)] opacity-[0.03] rounded-full blur-[100px]"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10 pt-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<!-- Левая колонка -->
|
||||
<div class="max-w-2xl text-center lg:text-left">
|
||||
<!-- Тег -->
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-500/10 border border-green-500/20 rounded-full text-green-400 text-xs font-bold uppercase tracking-wider mb-8"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-400"
|
||||
></span>
|
||||
</span>
|
||||
Авторист в Сургуте
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] leading-[1.1] mb-6"
|
||||
>
|
||||
{
|
||||
title.split(" ").map((word, i) =>
|
||||
i === 1 || i === 2 ? (
|
||||
<span class="text-[var(--color-gold)] relative inline-block">
|
||||
{word}
|
||||
<span class="absolute -bottom-2 left-0 w-full h-[2px] bg-[var(--color-gold)]/30 rounded-full" />
|
||||
</span>
|
||||
) : (
|
||||
<span>{word} </span>
|
||||
),
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-[var(--color-gray-400)] text-lg md:text-xl leading-relaxed mb-10 border-l-4 border-[var(--color-gold)] pl-6"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-12">
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="group relative px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-xl overflow-hidden transition-all hover:shadow-[0_0_30px_rgba(191,155,88,0.3)] hover:-translate-y-1 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700"
|
||||
></span>
|
||||
<span class="relative flex items-center justify-center gap-3">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
></path>
|
||||
</svg>
|
||||
Получить консультацию
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
href="#services"
|
||||
class="group px-8 py-4 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/30 text-[var(--color-white)] font-bold rounded-xl hover:bg-[var(--color-navy-darker)] hover:border-[var(--color-gold)]/30 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
Услуги
|
||||
<svg
|
||||
class="w-4 h-4 group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="flex gap-8 pt-8 border-t border-[var(--color-gray-600)]/20">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-[var(--color-gold)]">500+</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">Дел выиграно</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-[var(--color-gold)]">10+</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">Лет опыта</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-[var(--color-gold)]">98%</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">Успешных дел</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка -->
|
||||
<div class="hidden lg:block relative">
|
||||
<div
|
||||
class="absolute -inset-1 bg-[var(--color-gold)] rounded-3xl blur opacity-20"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-3xl p-8 shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<div
|
||||
class="w-14 h-14 bg-[var(--color-gold)] rounded-2xl flex items-center justify-center shadow-lg shadow-[var(--color-gold)]/20"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-[var(--color-white)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-[var(--color-white)] font-bold text-xl">
|
||||
Срочная помощь
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-sm">
|
||||
Ответим за 15 минут
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-4 mb-8">
|
||||
<li class="flex items-start gap-3 group">
|
||||
<span
|
||||
class="w-6 h-6 rounded-lg bg-[var(--color-emerald-500)]/20 text-[var(--color-emerald-400)] flex items-center justify-center text-xs font-bold flex-shrink-0"
|
||||
>1</span
|
||||
>
|
||||
<span class="text-[var(--color-gray-400)] text-sm"
|
||||
>Не слушайте инспектора ДПС</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 group">
|
||||
<span
|
||||
class="w-6 h-6 rounded-lg bg-[var(--color-emerald-500)]/20 text-[var(--color-emerald-400)] flex items-center justify-center text-xs font-bold flex-shrink-0"
|
||||
>2</span
|
||||
>
|
||||
<span class="text-[var(--color-gray-400)] text-sm"
|
||||
>Не признавайте вину в нарушении ПДД</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 group">
|
||||
<span
|
||||
class="w-6 h-6 rounded-lg bg-[var(--color-emerald-500)]/20 text-[var(--color-emerald-400)] flex items-center justify-center text-xs font-bold flex-shrink-0"
|
||||
>2</span
|
||||
>
|
||||
<span class="text-[var(--color-gray-400)] text-sm"
|
||||
>Сделайте запись в протоколе - "ПДД не нарушал"</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span
|
||||
class="text-[var(--color-gold)] text-xs uppercase tracking-wider block mb-1"
|
||||
>Бесплатно</span
|
||||
>
|
||||
<span class="text-[var(--color-white)] font-bold"
|
||||
>Первая консультация</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-[var(--color-gold)]/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@keyframes slow-zoom {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
.animate-slow-zoom {
|
||||
animation: slow-zoom 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Центрирование заголовка на мобильных устройствах */
|
||||
@media (max-width: 767px) {
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Центрирование блока "Авторист в Сургуте" и добавление отступа сверху на мобильных устройствах */
|
||||
@media (max-width: 767px) {
|
||||
.container > div:first-child > div:first-child > div:first-child {
|
||||
text-align: center;
|
||||
margin-top: 2rem; /* Увеличиваем отступ сверху */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/components/services/arbitration/Advantages.astro
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
const advantages = [
|
||||
{
|
||||
value: "500+",
|
||||
label: "Дел в арбитраже",
|
||||
description: "Успешно проведённых дел в арбитражных судах всех инстанций"
|
||||
},
|
||||
{
|
||||
value: "92%",
|
||||
label: "Успешных решений",
|
||||
description: "Клиенты получили благоприятный исход дела"
|
||||
},
|
||||
{
|
||||
value: "₽500М+",
|
||||
label: "Взыскано",
|
||||
description: "Реально взысканных средств для клиентов за последние 5 лет"
|
||||
},
|
||||
{
|
||||
value: "15+",
|
||||
label: "Лет практики",
|
||||
description: "Специализируюсь исключительно на арбитражных спорах"
|
||||
},
|
||||
{
|
||||
value: "24/7",
|
||||
label: "На связи",
|
||||
description: "Оперативное информирование клиентов о ходе дела"
|
||||
},
|
||||
{
|
||||
value: "0₽",
|
||||
label: "Первичный анализ",
|
||||
description: "Бесплатная оценка перспектив дела и консультация"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 bg-[var(--color-navy)] relative overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute top-[25%] right-[5%] w-[400px] h-[400px] bg-[var(--color-gold)]/3 rounded-full blur-[120px]"></div>
|
||||
<div class="absolute bottom-[25%] left-[5%] w-[350px] h-[350px] bg-[var(--color-blue-primary)]/3 rounded-full blur-[100px]"></div>
|
||||
|
||||
<!-- Сетка на фоне -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.02]"
|
||||
style="background-image: linear-gradient(var(--color-gold) 1px, transparent 1px), linear-gradient(90deg, var(--color-gold) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-6">
|
||||
Факты
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6">
|
||||
ПРЕИМУЩЕСТВА<br />
|
||||
<span class="text-[var(--color-gold)]">В ЦИФРАХ</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg max-w-2xl mx-auto">
|
||||
Объективные показатели моей работы
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Сетка преимуществ -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{advantages.map((item) => (
|
||||
<div class="group relative bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/10 p-8 rounded-xl hover:border-[var(--color-gold)]/30 transition-all duration-500 hover:-translate-y-1">
|
||||
<!-- Декоративный уголок -->
|
||||
<div class="absolute top-0 right-0 w-0 h-0 border-t-2 border-r-2 border-[var(--color-gold)] group-hover:w-8 group-hover:h-8 transition-all duration-500"></div>
|
||||
|
||||
<!-- Значение -->
|
||||
<div class="text-4xl md:text-5xl font-black text-[var(--color-gold)] mb-3 group-hover:scale-110 transition-transform origin-left">
|
||||
{item.value}
|
||||
</div>
|
||||
|
||||
<!-- Метка -->
|
||||
<div class="text-[var(--color-white)] font-bold text-sm uppercase tracking-wider mb-4">
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-500)] text-sm leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Декоративная полоса снизу -->
|
||||
<div class="mt-16 flex items-center justify-center gap-4">
|
||||
<div class="h-px w-16 bg-gradient-to-r from-transparent to-[var(--color-gold)]/50"></div>
|
||||
<div class="flex items-center gap-2 text-[var(--color-gray-500)] text-sm">
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rotate-45"></span>
|
||||
<span>Работаю на результат с 2009 года</span>
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rotate-45"></span>
|
||||
</div>
|
||||
<div class="h-px w-16 bg-gradient-to-l from-transparent to-[var(--color-gold)]/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Арбитражные споры",
|
||||
subtitle = "Защита интересов бизнеса в арбитражных судах всех инстанций. От Первого арбитражного суда до Верховного суда РФ.",
|
||||
} = Astro.props;
|
||||
|
||||
const stats = [
|
||||
{ value: "500+", label: "Дел в арбитраже" },
|
||||
{ value: "92%", label: "Успешных решений" },
|
||||
{ value: "15+", label: "Лет практики" },
|
||||
{ value: "₽500М+", label: "Взыскано для клиентов" },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative min-h-screen flex items-center bg-[var(--color-navy)] overflow-hidden">
|
||||
<!-- Фоновая сетка -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.03]"
|
||||
style="background-image: linear-gradient(var(--color-gold) 1px, transparent 1px), linear-gradient(90deg, var(--color-gold) 1px, transparent 1px); background-size: 80px 80px;"
|
||||
></div>
|
||||
|
||||
<!-- Градиентные пятна -->
|
||||
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-to-bl from-[var(--color-gold)]/10 to-transparent rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-to-tr from-[var(--color-blue-primary)]/5 to-transparent rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративные линии -->
|
||||
<div class="absolute inset-0 z-0 pointer-events-none">
|
||||
<div class="absolute top-[20%] left-0 w-[30%] h-px bg-gradient-to-r from-[var(--color-gold)]/40 to-transparent"></div>
|
||||
<div class="absolute top-[40%] right-0 w-[40%] h-px bg-gradient-to-l from-[var(--color-gold)]/30 to-transparent"></div>
|
||||
<div class="absolute bottom-[30%] left-[10%] w-[25%] h-px bg-gradient-to-r from-[var(--color-gold)]/20 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10 py-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
<!-- Левая часть - контент -->
|
||||
<div class="lg:col-span-7">
|
||||
<!-- Бейдж -->
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-[var(--color-gold)] rounded-sm rotate-45"></span>
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)]/60 rounded-sm rotate-45"></span>
|
||||
<span class="w-1 h-1 bg-[var(--color-gold)]/40 rounded-sm rotate-45"></span>
|
||||
</div>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.3em] ml-4">
|
||||
Арбитражная практика
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h1 class="text-5xl md:text-7xl font-bold text-[var(--color-white)] leading-[1.05] mb-8">
|
||||
{
|
||||
title
|
||||
.split(" ")
|
||||
.map((word, i) =>
|
||||
i === 0 ? (
|
||||
<span class="block text-[var(--color-gold)] mb-2">{word}</span>
|
||||
) : (
|
||||
<span class="inline-block mr-4">{word}</span>
|
||||
),
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-400)] text-lg md:text-xl leading-relaxed mb-10 max-w-xl">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-12">
|
||||
<a
|
||||
href="#contact"
|
||||
class="group px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-lg transition-all hover:shadow-[0_0_30px_rgba(191,155,88,0.4)] flex items-center justify-center gap-3"
|
||||
>
|
||||
<span>Бесплатная консультация</span>
|
||||
<svg class="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="px-8 py-4 border border-[var(--color-gray-600)]/50 text-[var(--color-white)] font-bold rounded-lg hover:border-[var(--color-gold)]/50 hover:bg-[var(--color-gold)]/5 transition-all flex items-center justify-center gap-3 cursor-pointer"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
||||
</svg>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Статистика в ряд -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 pt-8 border-t border-[var(--color-gray-600)]/20">
|
||||
{stats.map((stat) => (
|
||||
<div>
|
||||
<div class="text-2xl md:text-3xl font-black text-[var(--color-gold)] mb-1">{stat.value}</div>
|
||||
<div class="text-xs text-[var(--color-gray-500)] uppercase tracking-wider">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая часть - визуальный элемент -->
|
||||
<div class="lg:col-span-5 hidden lg:block">
|
||||
<div class="relative">
|
||||
<!-- Основная карточка -->
|
||||
<div class="relative bg-[var(--color-navy-dark)]/80 backdrop-blur-sm border border-[var(--color-gray-600)]/20 p-8 rounded-2xl">
|
||||
<!-- Заголовок карточки -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-10 h-10 bg-[var(--color-gold)]/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[var(--color-white)] font-bold text-sm">Арбитражный суд</div>
|
||||
<div class="text-[var(--color-gray-500)] text-xs">Все инстанции РФ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список инстанций -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-3 border-b border-[var(--color-gray-600)]/20">
|
||||
<span class="text-[var(--color-gray-400)] text-sm">Первая инстанция</span>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold">✓</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-[var(--color-gray-600)]/20">
|
||||
<span class="text-[var(--color-gray-400)] text-sm">Апелляция</span>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold">✓</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-[var(--color-gray-600)]/20">
|
||||
<span class="text-[var(--color-gray-400)] text-sm">Кассация</span>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold">✓</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<span class="text-[var(--color-gray-400)] text-sm">Верховный суд</span>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративная линия снизу -->
|
||||
<div class="mt-6 pt-6 border-t border-[var(--color-gray-600)]/20">
|
||||
<div class="flex items-center gap-2 text-[var(--color-gray-500)] text-xs">
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rounded-full animate-pulse"></span>
|
||||
<span>Работаю по всему Уральскому округу</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute -top-4 -right-4 w-24 h-24 bg-gradient-to-br from-[var(--color-gold)]/10 to-transparent rounded-full blur-xl"></div>
|
||||
<div class="absolute -bottom-4 -left-4 w-32 h-32 bg-gradient-to-tr from-[var(--color-gold)]/5 to-transparent rounded-full blur-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
const instances = [
|
||||
{
|
||||
level: "Первая инстанция",
|
||||
court: "Арбитражный суд Ханты-Мансийского автономного округа — Югры",
|
||||
description: "Рассмотрение дела по существу, сбор и представление доказательств",
|
||||
icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
},
|
||||
{
|
||||
level: "Апелляция",
|
||||
court: "Восьмой арбитражный апелляционный суд",
|
||||
description: "Проверка законности и обоснованности решения первой инстанции",
|
||||
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
},
|
||||
{
|
||||
level: "Кассация",
|
||||
court: "Арбитражный суд Уральского округа",
|
||||
description: "Проверка правильности применения норм материального и процессуального права",
|
||||
icon: "M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"
|
||||
},
|
||||
{
|
||||
level: "Высшая инстанция",
|
||||
court: "Верховный Суд Российской Федерации",
|
||||
description: "Пересмотр судебных актов в порядке надзора, обеспечение единообразия практики",
|
||||
icon: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 bg-[var(--color-navy-dark)] relative overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute top-[20%] left-[10%] w-[400px] h-[400px] bg-[var(--color-gold)]/3 rounded-full blur-[120px]"></div>
|
||||
<div class="absolute bottom-[20%] right-[10%] w-[350px] h-[350px] bg-[var(--color-blue-primary)]/3 rounded-full blur-[100px]"></div>
|
||||
|
||||
<!-- Вертикальная линия -->
|
||||
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-transparent via-[var(--color-gold)]/20 to-transparent hidden lg:block"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-20">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-6">
|
||||
География дел
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6">
|
||||
АРБИТРАЖНЫЕ<br />
|
||||
<span class="text-[var(--color-gold)]">СУДЫ</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg max-w-2xl mx-auto">
|
||||
Представительство во всех арбитражных инстанциях Российской Федерации
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Список инстанций -->
|
||||
<div class="space-y-6 lg:space-y-0">
|
||||
{instances.map((instance, index) => (
|
||||
<div class={`relative lg:grid lg:grid-cols-2 lg:gap-16 ${index % 2 === 0 ? '' : 'lg:direction-reverse'}`}>
|
||||
<!-- Левая/Правая часть в зависимости от индекса -->
|
||||
<div class={`lg:pr-16 ${index % 2 === 0 ? 'lg:text-right lg:col-start-1' : 'lg:col-start-2 lg:pl-16 lg:pr-0 lg:text-left'}`}>
|
||||
<div class="group bg-[var(--color-navy)] border border-[var(--color-gray-600)]/10 p-8 rounded-xl hover:border-[var(--color-gold)]/30 transition-all duration-500">
|
||||
<!-- Уровень и иконка -->
|
||||
<div class={`flex items-center gap-4 mb-6 ${index % 2 === 0 ? 'lg:justify-end' : 'lg:justify-start'}`}>
|
||||
<div class="w-12 h-12 bg-[var(--color-gold)]/5 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={instance.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider">{instance.level}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Название суда -->
|
||||
<h3 class="text-xl md:text-2xl font-bold text-[var(--color-white)] mb-4 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{instance.court}
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-500)] leading-relaxed">
|
||||
{instance.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Точка на линии (только для десктопа) -->
|
||||
<div class="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<div class="w-4 h-4 rounded-full bg-[var(--color-navy)] border-2 border-[var(--color-gold)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Пустая колонка для сетки -->
|
||||
{index % 2 === 0 ? <div class="hidden lg:block"></div> : <div class="hidden lg:block lg:col-start-1"></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Декоративная плашка снизу -->
|
||||
<div class="mt-16 text-center">
|
||||
<div class="inline-flex items-center gap-3 px-6 py-3 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-full">
|
||||
<span class="w-2 h-2 bg-[var(--color-gold)] rounded-full animate-pulse"></span>
|
||||
<span class="text-[var(--color-gray-400)] text-sm">Работаю по всему Уральскому арбитражному округу</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
200
frontend/src/components/services/arbitration/EmergencyHelp.astro
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
import SocialIcons from "@components/base/SocialIcons.astro";
|
||||
|
||||
const benefits = [
|
||||
{ value: "15", label: "минут", desc: "Среднее время ответа" },
|
||||
{ value: "0", label: "₽", desc: "Первичная консультация" },
|
||||
{ value: "100%", label: "", desc: "Конфиденциальность" },
|
||||
];
|
||||
---
|
||||
|
||||
<section
|
||||
class="py-16 md:py-24 bg-[var(--color-navy-dark)] relative overflow-hidden"
|
||||
>
|
||||
<!-- Диагональный разделитель -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-16 md:h-24 bg-[var(--color-navy)] transform -skew-y-2 origin-top-left"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Фон -->
|
||||
<div class="absolute inset-0">
|
||||
<div
|
||||
class="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,_var(--color-gold)_0%,_transparent_50%)] opacity-[0.03]"
|
||||
>
|
||||
</div>
|
||||
<!-- Геометрический паттерн -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.02]"
|
||||
style="background-image: linear-gradient(45deg, var(--color-gold) 1px, transparent 1px), linear-gradient(-45deg, var(--color-gold) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10 pt-8 md:pt-12"
|
||||
>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-10 md:gap-16 items-center">
|
||||
<!-- Левая часть - форма быстрой связи -->
|
||||
<div class="relative order-2 lg:order-1">
|
||||
<div
|
||||
class="absolute -inset-4 bg-[var(--color-gold)]/5 rounded-3xl blur-2xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-[var(--color-navy)] border border-[var(--color-gray-600)]/10 rounded-2xl md:rounded-3xl p-6 md:p-8 lg:p-10"
|
||||
>
|
||||
<h3
|
||||
class="text-xl md:text-2xl font-bold text-[var(--color-white)] mb-2 text-center md:text-left"
|
||||
>
|
||||
Срочная консультация
|
||||
</h3>
|
||||
<p
|
||||
class="text-[var(--color-gray-500)] text-sm md:text-base mb-6 md:mb-8 text-center md:text-left"
|
||||
>
|
||||
Опишите ситуацию — перезвоним за 15 минут
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="space-y-3 md:space-y-4"
|
||||
onsubmit="event.preventDefault(); alert('Заявка отправлена!');"
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ваше имя"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors text-sm md:text-base"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Телефон"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors text-sm md:text-base"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-gray-400)] focus:border-[var(--color-gold)] focus:outline-none transition-colors text-sm md:text-base"
|
||||
>
|
||||
<option>Выберите тип дела</option>
|
||||
<option>Договорные споры</option>
|
||||
<option>Корпоративные споры</option>
|
||||
<option>Банкротство</option>
|
||||
<option>Налоговые споры</option>
|
||||
<option>Недвижимость и земля</option>
|
||||
<option>Госзакупки</option>
|
||||
<option>Другое</option>
|
||||
</select>
|
||||
<textarea
|
||||
placeholder="Краткое описание ситуации"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-xl text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors resize-none text-sm md:text-base"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-3 md:py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-xl transition-all hover:shadow-lg hover:shadow-[var(--color-gold)]/20 flex items-center justify-center gap-2 group text-sm md:text-base"
|
||||
>
|
||||
<span>Отправить заявку</span>
|
||||
<svg
|
||||
class="w-4 h-4 md:w-5 md:h-5 group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-xs text-[var(--color-gray-600)] mt-4 text-center">
|
||||
Нажимая кнопку, вы соглашаетесь с политикой конфиденциальности
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая часть - контакты -->
|
||||
<div class="order-1 lg:order-2 text-center lg:text-left">
|
||||
<span
|
||||
class="text-[var(--color-gold)] text-xs md:text-sm font-bold uppercase tracking-wider mb-3 md:mb-4 block"
|
||||
>Контакты</span
|
||||
>
|
||||
<h2
|
||||
class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-white)] mb-4 md:mb-6 leading-tight"
|
||||
>
|
||||
Нужна срочная <span class="text-[var(--color-gold)]">помощь</span> арбитражного управляющего?
|
||||
</h2>
|
||||
<p
|
||||
class="text-[var(--color-gray-400)] text-base md:text-lg mb-8 md:mb-10 leading-relaxed"
|
||||
>
|
||||
Арбитражные споры требуют немедленных действий. Чем раньше вы обратитесь, тем больше шансов на благоприятный исход для вашего бизнеса.
|
||||
</p>
|
||||
|
||||
<!-- Быстрые цифры -->
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-6 mb-8 md:mb-10"
|
||||
>
|
||||
{
|
||||
benefits.map((item) => (
|
||||
<div class="text-center p-4 bg-[var(--color-navy)] rounded-xl md:rounded-2xl border border-[var(--color-gray-600)]/10">
|
||||
<div class="text-2xl md:text-3xl font-black text-[var(--color-gold)] mb-1">
|
||||
{item.value}
|
||||
<span class="text-base md:text-lg">{item.label}</span>
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-[var(--color-gray-500)]">
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Телефон -->
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="group flex flex-col sm:flex-row items-center gap-4 p-4 md:p-6 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-xl md:rounded-2xl hover:bg-[var(--color-gold)]/20 transition-all mb-6 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 md:w-14 md:h-14 rounded-full bg-[var(--color-gold)] flex items-center justify-center shadow-lg shadow-[var(--color-gold)]/20 group-hover:scale-110 transition-transform shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 md:w-6 md:h-6 text-[var(--color-white)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-center sm:text-left">
|
||||
<div class="text-[var(--color-gray-500)] text-xs md:text-sm mb-1">
|
||||
Позвонить сейчас
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-[var(--color-white)] group-hover:text-[var(--color-gold)] transition-colors"
|
||||
>
|
||||
{CONTACT_CONSTANTS.phone}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Мессенджеры -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-3 sm:gap-4"
|
||||
>
|
||||
<span class="text-[var(--color-gray-500)] text-sm">Или напишите:</span
|
||||
>
|
||||
<SocialIcons variant="messenger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
147
frontend/src/components/services/arbitration/FinalCTA.astro
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
import SocialIcons from "@components/base/SocialIcons.astro";
|
||||
---
|
||||
|
||||
<section id="contact" class="py-24 bg-[var(--color-navy-dark)] relative overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- Диагональный градиент -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-navy)] via-[var(--color-navy)]/95 to-[var(--color-navy-dark)]"></div>
|
||||
|
||||
<!-- Сетка -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.02]"
|
||||
style="background-image: linear-gradient(45deg, var(--color-gold) 1px, transparent 1px), linear-gradient(-45deg, var(--color-gold) 1px, transparent 1px); background-size: 50px 50px;"
|
||||
></div>
|
||||
|
||||
<!-- Световые пятна -->
|
||||
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-[var(--color-gold)]/5 rounded-full blur-[150px]"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[400px] h-[400px] bg-[var(--color-blue-primary)]/5 rounded-full blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<!-- Левая часть - заголовок и контакты -->
|
||||
<div>
|
||||
<!-- Бейдж -->
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<span class="w-3 h-3 bg-[var(--color-gold)] rotate-45"></span>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em]">
|
||||
Связаться со мной
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6 leading-[0.95]">
|
||||
ГОТОВЫ<br />
|
||||
<span class="text-[var(--color-gold)]">НАЧАТЬ?</span>
|
||||
</h2>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-400)] text-lg mb-10 leading-relaxed">
|
||||
Оставьте заявку на бесплатную консультацию. Проанализирую вашу ситуацию
|
||||
и расскажу о перспективах дела.
|
||||
</p>
|
||||
|
||||
<!-- Телефон -->
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="group flex items-center gap-4 p-6 bg-[var(--color-navy)] border border-[var(--color-gray-600)]/20 rounded-xl hover:border-[var(--color-gold)]/30 transition-all mb-8 cursor-pointer"
|
||||
>
|
||||
<div class="w-14 h-14 bg-[var(--color-gold)] rounded-lg flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-[var(--color-white)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-gray-500)] text-xs uppercase tracking-wider mb-1">Позвонить сейчас</div>
|
||||
<div class="text-2xl font-bold text-[var(--color-white)] group-hover:text-[var(--color-gold)] transition-colors">{CONTACT_CONSTANTS.phone}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Мессенджеры -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[var(--color-gray-500)] text-sm">Или напишите:</span>
|
||||
<SocialIcons variant="messenger" />
|
||||
</div>
|
||||
|
||||
<!-- Гарантия -->
|
||||
<div class="mt-10 flex items-start gap-4 p-4 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/10">
|
||||
<div class="w-10 h-10 bg-[var(--color-gold)]/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[var(--color-white)] font-bold text-sm mb-1">Конфиденциальность гарантирована</div>
|
||||
<div class="text-[var(--color-gray-500)] text-xs">Вся информация защищена адвокатской тайной</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая часть - форма -->
|
||||
<div class="relative">
|
||||
<!-- Фоновое свечение -->
|
||||
<div class="absolute -inset-4 bg-[var(--color-gold)]/5 rounded-2xl blur-2xl"></div>
|
||||
|
||||
<!-- Форма -->
|
||||
<div class="relative bg-[var(--color-navy)] border border-[var(--color-gray-600)]/10 p-8">
|
||||
<h3 class="text-2xl font-bold text-[var(--color-white)] mb-2">Заявка на консультацию</h3>
|
||||
<p class="text-[var(--color-gray-500)] text-sm mb-6">Заполните форму — перезвоню в течение 15 минут</p>
|
||||
|
||||
<form class="space-y-4" onsubmit="event.preventDefault(); alert('Заявка отправлена!');">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ваше имя"
|
||||
class="w-full px-4 py-4 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-lg text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Телефон"
|
||||
class="w-full px-4 py-4 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-lg text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select class="w-full px-4 py-4 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-lg text-[var(--color-gray-400)] focus:border-[var(--color-gold)] focus:outline-none transition-colors">
|
||||
<option value="">Выберите тип дела</option>
|
||||
<option>Договорные споры</option>
|
||||
<option>Корпоративные споры</option>
|
||||
<option>Банкротство</option>
|
||||
<option>Налоговые споры</option>
|
||||
<option>Недвижимость и земля</option>
|
||||
<option>Госзакупки</option>
|
||||
<option>Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="Краткое описание ситуации"
|
||||
rows="4"
|
||||
class="w-full px-4 py-4 bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/20 rounded-lg text-[var(--color-white)] placeholder-[var(--color-gray-600)] focus:border-[var(--color-gold)] focus:outline-none transition-colors resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-lg transition-all hover:shadow-[0_0_30px_rgba(191,155,88,0.4)] flex items-center justify-center gap-3 group"
|
||||
>
|
||||
<span>Отправить заявку</span>
|
||||
<svg class="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-[var(--color-gray-600)] text-center">
|
||||
Нажимая кнопку, вы соглашаетесь с политикой конфиденциальности
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
109
frontend/src/components/services/arbitration/HowIWork.astro
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Консультация",
|
||||
description: "Анализирую вашу ситуацию, изучаю документы, оцениваю перспективы дела",
|
||||
details: "Бесплатно • 30-60 минут"
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Договор",
|
||||
description: "Заключаем договор на оказание юридических услуг, согласуем стратегию",
|
||||
details: "Прозрачные условия • Фиксированная цена"
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Работа по делу",
|
||||
description: "Готовлю документы, представляю ваши интересы в арбитражном суде",
|
||||
details: "Все инстанции • Полное сопровождение"
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Результат",
|
||||
description: "Получаем решение суда, контролируем исполнение",
|
||||
details: "Исполнительный лист • Взыскание средств"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 bg-[var(--color-navy-dark)] relative overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute top-[15%] left-[15%] w-[350px] h-[350px] bg-[var(--color-gold)]/3 rounded-full blur-[100px]"></div>
|
||||
<div class="absolute bottom-[15%] right-[15%] w-[300px] h-[300px] bg-[var(--color-blue-primary)]/3 rounded-full blur-[80px]"></div>
|
||||
|
||||
<!-- Горизонтальная линия прогресса (desktop) -->
|
||||
<div class="hidden lg:block absolute top-1/2 left-[10%] right-[10%] h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-20">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-6">
|
||||
Процесс работы
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6">
|
||||
КАК<br />
|
||||
<span class="text-[var(--color-gold)]">РАБОТАЮ</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg max-w-2xl mx-auto">
|
||||
Простой и понятный процесс взаимодействия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Шаги (горизонтально на desktop) -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{steps.map((step, index) => (
|
||||
<div class="relative group">
|
||||
<!-- Соединительная линия (desktop) -->
|
||||
{index < steps.length - 1 && (
|
||||
<div class="hidden lg:block absolute top-12 left-[60%] w-[80%] h-px bg-gradient-to-r from-[var(--color-gold)]/30 to-transparent"></div>
|
||||
)}
|
||||
|
||||
<!-- Карточка шага -->
|
||||
<div class="relative bg-[var(--color-navy)] border border-[var(--color-gray-600)]/10 p-8 rounded-xl hover:border-[var(--color-gold)]/30 transition-all duration-500 group-hover:-translate-y-2">
|
||||
<!-- Номер шага -->
|
||||
<div class="relative mb-8">
|
||||
<div class="text-6xl md:text-7xl font-black text-[var(--color-gold)]/10 group-hover:text-[var(--color-gold)]/20 transition-colors">
|
||||
{step.number}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-12 h-1 bg-[var(--color-gold)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-xl font-bold text-[var(--color-white)] mb-3 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-500)] text-sm leading-relaxed mb-4">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
<!-- Детали -->
|
||||
<div class="flex items-center gap-2 text-xs text-[var(--color-gray-600)]">
|
||||
<span class="w-1.5 h-1.5 bg-[var(--color-gold)] rounded-full"></span>
|
||||
<span>{step.details}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- CTA кнопка -->
|
||||
<div class="mt-16 text-center">
|
||||
<a
|
||||
href="#contact"
|
||||
class="inline-flex items-center gap-3 px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-lg transition-all hover:shadow-[0_0_30px_rgba(191,155,88,0.4)]"
|
||||
>
|
||||
<span>Начать работу над делом</span>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
105
frontend/src/components/services/arbitration/PracticeAreas.astro
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
const practices = [
|
||||
{
|
||||
title: "Договорные споры",
|
||||
description: "Взыскание задолженностей, расторжение договоров, неустойки и штрафы",
|
||||
icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||
price: "от 30 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Корпоративные споры",
|
||||
description: "Споры между участниками ООО, взыскание убытков с директоров, оспаривание сделок",
|
||||
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||
price: "от 50 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Банкротство",
|
||||
description: "Сопровождение процедур банкротства, оспаривание сделок, защита кредиторов",
|
||||
icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
price: "от 100 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Налоговые споры",
|
||||
description: "Обжалование решений налоговых органов, защита при проверках",
|
||||
icon: "M9 14l6-6m-5.5.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5zm0 0V5a2.5 2.5 0 115 0v6m-5 0h5",
|
||||
price: "от 40 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Недвижимость и земля",
|
||||
description: "Споры по объектам недвижимости, аренда, узаконивание построек",
|
||||
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
price: "от 35 000 ₽"
|
||||
},
|
||||
{
|
||||
title: "Госзакупки",
|
||||
description: "Споры по 44-ФЗ и 223-ФЗ, обжалование в ФАС, взыскание по контрактам",
|
||||
icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z",
|
||||
price: "от 25 000 ₽"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 bg-[var(--color-navy)] relative overflow-hidden">
|
||||
<!-- Декоративный элемент сверху -->
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute top-[10%] right-[5%] w-[300px] h-[300px] bg-[var(--color-gold)]/3 rounded-full blur-[100px]"></div>
|
||||
<div class="absolute bottom-[10%] left-[5%] w-[250px] h-[250px] bg-[var(--color-blue-primary)]/3 rounded-full blur-[80px]"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-6">
|
||||
Специализация
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6">
|
||||
НАПРАВЛЕНИЯ<br />
|
||||
<span class="text-[var(--color-gold)]">ПРАКТИКИ</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg max-w-2xl mx-auto">
|
||||
Комплексная защита бизнеса в арбитражных судах всех уровней
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Сетка карточек -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{practices.map((item, index) => (
|
||||
<div class="group relative bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/10 p-8 rounded-xl hover:border-[var(--color-gold)]/30 transition-all duration-500">
|
||||
<!-- Уголки при наведении -->
|
||||
<div class="absolute top-0 left-0 w-0 h-0 border-t-2 border-l-2 border-[var(--color-gold)] group-hover:w-6 group-hover:h-6 transition-all duration-500"></div>
|
||||
<div class="absolute top-0 right-0 w-0 h-0 border-t-2 border-r-2 border-[var(--color-gold)] group-hover:w-6 group-hover:h-6 transition-all duration-500"></div>
|
||||
<div class="absolute bottom-0 left-0 w-0 h-0 border-b-2 border-l-2 border-[var(--color-gold)] group-hover:w-6 group-hover:h-6 transition-all duration-500"></div>
|
||||
<div class="absolute bottom-0 right-0 w-0 h-0 border-b-2 border-r-2 border-[var(--color-gold)] group-hover:w-6 group-hover:h-6 transition-all duration-500"></div>
|
||||
|
||||
<!-- Иконка -->
|
||||
<div class="w-14 h-14 bg-[var(--color-gold)]/5 rounded-lg flex items-center justify-center mb-6 group-hover:bg-[var(--color-gold)]/10 transition-colors">
|
||||
<svg class="w-7 h-7 text-[var(--color-gold)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h3 class="text-xl font-bold text-[var(--color-white)] mb-3 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="text-[var(--color-gray-500)] text-sm leading-relaxed mb-6">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
<!-- Цена и разделитель -->
|
||||
<div class="pt-6 border-t border-[var(--color-gray-600)]/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[var(--color-gold)] font-bold text-lg">{item.price}</span>
|
||||
<svg class="w-5 h-5 text-[var(--color-gray-600)] group-hover:text-[var(--color-gold)] group-hover:translate-x-1 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
148
frontend/src/components/services/arbitration/Pricing.astro
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
---
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: "Консультация",
|
||||
price: "Бесплатно",
|
||||
period: "30 минут",
|
||||
description: "Первичный анализ ситуации",
|
||||
features: [
|
||||
"Анализ документов",
|
||||
"Оценка перспектив дела",
|
||||
"Консультация по стратегии",
|
||||
"Ответы на вопросы"
|
||||
],
|
||||
highlighted: false
|
||||
},
|
||||
{
|
||||
name: "Представительство",
|
||||
price: "от 30 000 ₽",
|
||||
period: "за этап",
|
||||
description: "Ведение дела в арбитражном суде",
|
||||
features: [
|
||||
"Подготовка искового заявления",
|
||||
"Участие в судебных заседаниях",
|
||||
"Представление доказательств",
|
||||
"Получение решения суда"
|
||||
],
|
||||
highlighted: true
|
||||
},
|
||||
{
|
||||
name: "Комплексное ведение",
|
||||
price: "от 100 000 ₽",
|
||||
period: "за дело",
|
||||
description: "Полное сопровождение дела",
|
||||
features: [
|
||||
"Все инстанции (первая + апелляция + кассация)",
|
||||
"Исполнительное производство",
|
||||
"Приставы и банки",
|
||||
"Гарантия результата"
|
||||
],
|
||||
highlighted: false
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 bg-[var(--color-navy)] relative overflow-hidden">
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/30 to-transparent"></div>
|
||||
|
||||
<!-- Фоновые элементы -->
|
||||
<div class="absolute top-[30%] right-[10%] w-[350px] h-[350px] bg-[var(--color-gold)]/3 rounded-full blur-[100px]"></div>
|
||||
<div class="absolute bottom-[30%] left-[10%] w-[300px] h-[300px] bg-[var(--color-blue-primary)]/3 rounded-full blur-[80px]"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок секции -->
|
||||
<div class="text-center mb-16">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/5 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-6">
|
||||
Прозрачные условия
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-white)] mb-6">
|
||||
СТОИМОСТЬ<br />
|
||||
<span class="text-[var(--color-gold)]">УСЛУГ</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg max-w-2xl mx-auto">
|
||||
Фиксированные тарифы без скрытых платежей
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Карточки тарифов -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{pricingPlans.map((plan, index) => (
|
||||
<div
|
||||
class={`relative p-8 rounded-xl ${
|
||||
plan.highlighted
|
||||
? 'bg-[var(--color-gold)] text-[var(--color-white)] transform md:-translate-y-4'
|
||||
: 'bg-[var(--color-navy-dark)] text-[var(--color-white)]'
|
||||
} border ${
|
||||
plan.highlighted
|
||||
? 'border-[var(--color-gold)] shadow-[0_0_40px_rgba(191,155,88,0.3)]'
|
||||
: 'border-[var(--color-gray-600)]/10'
|
||||
} transition-all duration-500 hover:-translate-y-2`}
|
||||
>
|
||||
<!-- Бейдж для выделенного тарифа -->
|
||||
{plan.highlighted && (
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<div class="px-4 py-1 bg-[var(--color-navy)] text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider rounded-full">
|
||||
Популярный
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Название тарифа -->
|
||||
<h3 class={`text-xl font-bold mb-2 ${plan.highlighted ? 'text-[var(--color-navy)]' : 'text-[var(--color-white)]'}`}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class={`text-sm mb-6 ${plan.highlighted ? 'text-[var(--color-navy)]/70' : 'text-[var(--color-gray-500)]'}`}>
|
||||
{plan.description}
|
||||
</p>
|
||||
|
||||
<!-- Цена -->
|
||||
<div class="mb-6">
|
||||
<div class={`text-3xl font-black mb-1 ${plan.highlighted ? 'text-[var(--color-navy)]' : 'text-[var(--color-gold)]'}`}>
|
||||
{plan.price}
|
||||
</div>
|
||||
<div class={`text-xs ${plan.highlighted ? 'text-[var(--color-navy)]/70' : 'text-[var(--color-gray-500)]'}`}>
|
||||
{plan.period}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Разделитель -->
|
||||
<div class={`h-px mb-6 ${plan.highlighted ? 'bg-[var(--color-navy)]/20' : 'bg-[var(--color-gray-600)]/20'}`}></div>
|
||||
|
||||
<!-- Список услуг -->
|
||||
<ul class="space-y-3 mb-8">
|
||||
{plan.features.map((feature) => (
|
||||
<li class={`flex items-start gap-3 text-sm ${plan.highlighted ? 'text-[var(--color-navy)]' : 'text-[var(--color-gray-400)]'}`}>
|
||||
<svg class={`w-5 h-5 flex-shrink-0 ${plan.highlighted ? 'text-[var(--color-navy)]' : 'text-[var(--color-gold)]'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<!-- Кнопка -->
|
||||
<a
|
||||
href="#contact"
|
||||
class={`block w-full py-4 text-center font-bold text-sm uppercase tracking-wider transition-all rounded-lg ${
|
||||
plan.highlighted
|
||||
? 'bg-[var(--color-navy)] text-[var(--color-white)] hover:bg-[var(--color-navy-dark)]'
|
||||
: 'bg-[var(--color-gold)] text-[var(--color-white)] hover:bg-[var(--color-gold-hover)]'
|
||||
}`}
|
||||
>
|
||||
Выбрать тариф
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Примечание снизу -->
|
||||
<div class="mt-12 text-center">
|
||||
<p class="text-[var(--color-gray-500)] text-sm">
|
||||
* Окончательная стоимость зависит от сложности дела и определяется после консультации
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
const steps = [
|
||||
{
|
||||
phase: "Этап 1",
|
||||
title: "Анализ ситуации",
|
||||
description: "Изучение документов, правовой анализ, оценка перспектив спора в арбитражном суде",
|
||||
details: ["Аудит документов", "Анализ судебной практики", "Оценка рисков"],
|
||||
duration: "1-3 дня"
|
||||
},
|
||||
{
|
||||
phase: "Этап 2",
|
||||
title: "Досудебная работа",
|
||||
description: "Обязательное соблюдение претензионного порядка, переговоры, подготовка позиции",
|
||||
details: ["Претензионное письмо", "Сбор доказательств", "Расчёт требований"],
|
||||
duration: "1-4 недели"
|
||||
},
|
||||
{
|
||||
phase: "Этап 3",
|
||||
title: "Первая инстанция",
|
||||
description: "Подача искового заявления, участие в судебных заседаниях, представление доказательств",
|
||||
details: ["Исковое заявление", "Ходатайства", "Судебные заседания"],
|
||||
duration: "2-6 месяцев"
|
||||
},
|
||||
{
|
||||
phase: "Этап 4",
|
||||
title: "Апелляция и кассация",
|
||||
description: "Обжалование решения, защита позиции в вышестоящих инстанциях",
|
||||
details: ["Апелляционная жалоба", "Кассационная жалоба", "Защита в суде"],
|
||||
duration: "2-4 месяца"
|
||||
},
|
||||
{
|
||||
phase: "Этап 5",
|
||||
title: "Исполнение решения",
|
||||
description: "Получение исполнительного листа, контроль исполнения решения суда",
|
||||
details: ["Исполнительный лист", "Банк или ФССП", "Контроль взыскания"],
|
||||
duration: "1-6 месяцев"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section id="process" class="py-24 bg-[var(--color-navy)] relative overflow-hidden">
|
||||
<!-- Вертикальная линия по центру -->
|
||||
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-transparent via-[var(--color-gold)]/20 to-transparent hidden lg:block"></div>
|
||||
|
||||
<!-- Декоративные элементы -->
|
||||
<div class="absolute top-1/4 left-1/4 w-64 h-64 bg-[var(--color-gold)]/5 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-80 h-80 bg-[var(--color-blue-primary)]/5 rounded-full blur-3xl"></div>
|
||||
|
||||
<div class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10">
|
||||
<!-- Заголовок -->
|
||||
<div class="text-center max-w-3xl mx-auto mb-20">
|
||||
<span class="inline-block px-4 py-2 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider mb-6">
|
||||
Как работаем
|
||||
</span>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-[var(--color-white)] mb-6">
|
||||
Путь к <span class="text-[var(--color-gold)]">результату</span>
|
||||
</h2>
|
||||
<p class="text-[var(--color-gray-500)] text-lg">
|
||||
Пошаговый алгоритм ведения арбитражного дела от консультации до исполнения решения суда
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Временная линия -->
|
||||
<div class="space-y-12 lg:space-y-0">
|
||||
{steps.map((step, index) => (
|
||||
<div class={`relative lg:grid lg:grid-cols-2 lg:gap-16 ${index !== steps.length - 1 ? 'lg:pb-16' : ''}`}>
|
||||
<!-- Точка на линии -->
|
||||
<div class="hidden lg:flex absolute left-1/2 top-0 -translate-x-1/2 z-10">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--color-navy)] border-4 border-[var(--color-gold)] flex items-center justify-center shadow-lg shadow-[var(--color-gold)]/20">
|
||||
<span class="text-[var(--color-gold)] font-bold text-sm">{index + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент слева/справа -->
|
||||
<div class={`lg:pr-16 ${index % 2 === 0 ? 'lg:text-right lg:col-start-1' : 'lg:col-start-2 lg:pl-16 lg:pr-0'}`}>
|
||||
<div class="group bg-[var(--color-navy-dark)] border border-[var(--color-gray-600)]/10 rounded-2xl p-8 hover:border-[var(--color-gold)]/30 transition-all duration-500 hover:-translate-y-1 text-center lg:text-left">
|
||||
<!-- Верхняя строка: фаза и длительность -->
|
||||
<div class={`flex items-center justify-between mb-4 ${index % 2 === 0 ? 'lg:justify-end' : 'lg:justify-start'}`}>
|
||||
<span class="text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider">{step.phase}</span>
|
||||
<span class="px-3 py-1 bg-[var(--color-gold)]/10 rounded-full text-[var(--color-gold)] text-xs">{step.duration}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-[var(--color-white)] mb-3 group-hover:text-[var(--color-gold)] transition-colors">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-gray-500)] mb-6 leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
<!-- Детали - центрируем контейнер, но текст по левому краю -->
|
||||
<div class="flex justify-center lg:block">
|
||||
<ul class="inline-block text-left space-y-2">
|
||||
{step.details.map((detail) => (
|
||||
<li class={`flex items-center gap-2 text-sm text-[var(--color-gray-400)] ${index % 2 === 0 ? 'lg:flex-row-reverse lg:text-right' : ''}`}>
|
||||
<svg class="w-4 h-4 text-[var(--color-gold)] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span>{detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Пустая колонка для сетки -->
|
||||
{index % 2 === 0 ? <div class="hidden lg:block"></div> : <div class="hidden lg:block lg:col-start-1 lg:row-start-1"></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
import CardServiceGrid, { type Service } from "@components/base/CardServiceGrid.astro";
|
||||
|
||||
const services: Service[] = [
|
||||
{
|
||||
title: "Договорные споры",
|
||||
description: "Взыскание задолженностей, расторжение договоров, неустойки и штрафы",
|
||||
icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||
price: "от 30 000 ₽",
|
||||
duration: "180 дел"
|
||||
},
|
||||
{
|
||||
title: "Корпоративные споры",
|
||||
description: "Споры между участниками ООО, взыскание убытков с директоров, оспаривание сделок",
|
||||
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||
price: "от 50 000 ₽",
|
||||
duration: "95 дел"
|
||||
},
|
||||
{
|
||||
title: "Банкротство",
|
||||
description: "Сопровождение процедур банкротства, оспаривание сделок, защита кредиторов",
|
||||
icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
price: "от 100 000 ₽",
|
||||
duration: "67 дел"
|
||||
},
|
||||
{
|
||||
title: "Налоговые споры",
|
||||
description: "Обжалование решений налоговых органов, защита при проверках",
|
||||
icon: "M9 14l6-6m-5.5.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5zm0 0V5a2.5 2.5 0 115 0v6m-5 0h5",
|
||||
price: "от 40 000 ₽",
|
||||
duration: "52 дела"
|
||||
},
|
||||
{
|
||||
title: "Недвижимость и земля",
|
||||
description: "Споры по объектам недвижимости, аренда, узаконивание построек",
|
||||
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
price: "от 35 000 ₽",
|
||||
duration: "78 дел"
|
||||
},
|
||||
{
|
||||
title: "Госзакупки",
|
||||
description: "Споры по 44-ФЗ и 223-ФЗ, обжалование в ФАС, взыскание по контрактам",
|
||||
icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z",
|
||||
price: "от 25 000 ₽",
|
||||
duration: "43 дела"
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<CardServiceGrid
|
||||
services={services}
|
||||
sectionId="services"
|
||||
title={'Направления <span class="text-[var(--color-gold)]">арбитражной</span> практики'}
|
||||
subtitle="Спектр услуг"
|
||||
description="Комплексная защита бизнеса в арбитражных судах всех уровней — от Первого арбитражного суда до Верховного суда РФ"
|
||||
bgColor="navy"
|
||||
accentColor="gold"
|
||||
/>
|
||||
346
frontend/src/components/services/arbitration/ServicesHero.astro
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
---
|
||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Арбитражные споры",
|
||||
subtitle = "Защита интересов бизнеса в арбитражных судах всех инстанций. Корпоративные споры, банкротство, договорные конфликты.",
|
||||
} = Astro.props;
|
||||
|
||||
const features = [
|
||||
"Представительство в арбитражных судах",
|
||||
"Корпоративные споры любой сложности",
|
||||
"Сопровождение банкротства",
|
||||
];
|
||||
---
|
||||
|
||||
<section
|
||||
class="relative min-h-screen flex items-center overflow-hidden bg-[var(--color-navy)]"
|
||||
>
|
||||
<!-- Диагональный фон с бизнес-тематикой -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-[var(--color-navy)] via-[var(--color-navy)] to-[var(--color-navy-dark)]"
|
||||
>
|
||||
</div>
|
||||
<!-- Геометрические линии -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full opacity-[0.03]"
|
||||
style="background-image: linear-gradient(45deg, var(--color-gold) 1px, transparent 1px), linear-gradient(-45deg, var(--color-gold) 1px, transparent 1px); background-size: 60px 60px;"
|
||||
>
|
||||
</div>
|
||||
<!-- Световые акценты -->
|
||||
<div
|
||||
class="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-[var(--color-gold)] opacity-[0.02]"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-1/3 h-2/3 bg-[var(--color-blue-primary)] opacity-[0.03] rounded-full blur-[150px]"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Анимированные линии -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
class="absolute top-1/4 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/20 to-transparent animate-slide-right"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1/2 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/10 to-transparent animate-slide-left"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-3/4 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--color-gold)]/20 to-transparent animate-slide-right"
|
||||
style="animation-delay: 1s;"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Плавающие декорации -->
|
||||
<div
|
||||
class="absolute top-20 right-20 w-32 h-32 border border-[var(--color-gold)]/10 rounded-lg animate-float transform rotate-12"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-40 right-40 w-24 h-24 border border-[var(--color-gold)]/20 rounded-lg animate-float-delayed transform -rotate-6"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-32 left-20 w-40 h-40 bg-[var(--color-gold)]/5 rounded-full blur-3xl animate-pulse-slow"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="container mx-auto px-4 md:px-8 lg:px-16 relative z-10 pt-8 md:pt-0"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto md:mx-0">
|
||||
<!-- Тег -->
|
||||
<div class="flex justify-center md:justify-start mb-8">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-gold)]/10 border border-[var(--color-gold)]/20 rounded-full text-[var(--color-gold)] text-xs font-bold uppercase tracking-wider backdrop-blur-sm"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 bg-[var(--color-gold)] rounded-full animate-pulse"
|
||||
></span>
|
||||
Арбитражная практика
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок с выделением -->
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-bold text-[var(--color-white)] leading-[1.05] mb-8 text-center md:text-left"
|
||||
>
|
||||
{
|
||||
title
|
||||
.split(" ")
|
||||
.map((word, i) =>
|
||||
i === 0 ? (
|
||||
<span class="block text-[var(--color-gold)] mb-2">{word}</span>
|
||||
) : (
|
||||
<span class="inline-block mr-4">{word}</span>
|
||||
),
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
|
||||
<!-- Подзаголовок с линией -->
|
||||
<div class="relative pl-0 md:pl-8 mb-12 text-center md:text-left">
|
||||
<div
|
||||
class="hidden md:block absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-[var(--color-gold)] to-transparent"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
class="text-[var(--color-gray-400)] text-xl md:text-2xl leading-relaxed"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые фичи -->
|
||||
<div class="flex flex-wrap gap-4 mb-12 justify-center md:justify-start">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-[var(--color-navy-dark)]/50 border border-[var(--color-gray-600)]/20 rounded-lg text-[var(--color-gray-400)] text-sm">
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-gold)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{feature}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA группа -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
data-consultation-modal
|
||||
class="group relative px-8 py-4 bg-[var(--color-gold)] hover:bg-[var(--color-gold-hover)] text-[var(--color-white)] font-bold rounded-lg overflow-hidden transition-all hover:shadow-[0_0_40px_rgba(191,155,88,0.3)] hover:-translate-y-1 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700"
|
||||
></span>
|
||||
<span class="relative flex items-center justify-center gap-3">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
></path>
|
||||
</svg>
|
||||
Бесплатная консультация
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick="document.getElementById('calculator').scrollIntoView({behavior: 'smooth'})"
|
||||
class="px-8 py-4 bg-transparent border-2 border-[var(--color-gray-600)]/30 text-[var(--color-white)] font-bold rounded-lg hover:border-[var(--color-gold)]/50 hover:bg-[var(--color-gold)]/5 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)] group-hover:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Рассчитать стоимость
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Статистика в ряд -->
|
||||
<div class="mt-16 pt-8 border-t border-[var(--color-gray-600)]/10">
|
||||
<div class="grid grid-cols-3 gap-8 text-center md:text-left">
|
||||
<div>
|
||||
<div class="text-3xl font-black text-[var(--color-gold)] mb-1">
|
||||
500+
|
||||
</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">
|
||||
Арбитражных дел
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-black text-[var(--color-gold)] mb-1">
|
||||
92%
|
||||
</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">
|
||||
Успешных решений
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-black text-[var(--color-gold)] mb-1">
|
||||
15+ лет
|
||||
</div>
|
||||
<div class="text-sm text-[var(--color-gray-500)]">
|
||||
В арбитраже
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Декоративная плашка справа -->
|
||||
<div class="hidden xl:block absolute right-16 top-1/2 -translate-y-1/2">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute -inset-4 bg-[var(--color-gold)]/10 rounded-2xl blur-xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="relative bg-[var(--color-navy-dark)]/80 backdrop-blur border border-[var(--color-gold)]/20 rounded-2xl p-6 w-80"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-[var(--color-gold)]/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-gold)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[var(--color-white)] font-bold text-sm">
|
||||
Арбитражный суд
|
||||
</div>
|
||||
<div class="text-[var(--color-gray-500)] text-xs">
|
||||
Все инстанции
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-[var(--color-gray-500)]">Первая инстанция</span>
|
||||
<span class="text-[var(--color-gold)]">24/7</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-[var(--color-gray-500)]">Апелляция</span>
|
||||
<span class="text-[var(--color-gold)]">24/7</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-[var(--color-gray-500)]">Кассация</span>
|
||||
<span class="text-[var(--color-gold)]">Пн-Пт</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@keyframes slide-right {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@keyframes slide-left {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(12deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(17deg);
|
||||
}
|
||||
}
|
||||
@keyframes float-delayed {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-6deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-15px) rotate(-1deg);
|
||||
}
|
||||
}
|
||||
@keyframes pulse-slow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.05;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-right {
|
||||
animation: slide-right 8s linear infinite;
|
||||
}
|
||||
.animate-slide-left {
|
||||
animation: slide-left 8s linear infinite;
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 8s ease-in-out infinite;
|
||||
}
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 4s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||