Создан компонент поста

This commit is contained in:
Web-serfer 2026-04-09 22:22:55 +05:00
parent 674ef7fe04
commit d0f41672d1
32 changed files with 2082 additions and 289 deletions

View file

@ -2,9 +2,11 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import node from '@astrojs/node';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
integrations: [mdx()],
vite: {
plugins: [tailwindcss()],
},

View file

@ -12,6 +12,7 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/node": "^10.0.4",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.0.8",

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,73 @@
---
interface Props {
title: string;
description: string;
buttonText: string;
buttonHref: string;
className?: string;
}
const {
title = 'Авторизуйтесь, чтобы продолжить',
description = 'Для продолжения, пожалуйста, войдите в личный кабинет.',
buttonText = 'Войти в кабинет',
buttonHref = '/auth/sign-in',
className = ''
} = Astro.props;
---
<div class={`auth-lock-card ${className}`}>
<div class="lock-icon-container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lock-icon">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="lock-title">{title}</h3>
<p class="lock-text" style:display={description ? 'block' : 'none'}>{description}</p>
<a href={buttonHref} class="auth-button">{buttonText}</a>
</div>
<style>
.auth-lock-card {
max-width: 600px;
margin: 0 auto;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 1.5rem;
padding: 4rem 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.lock-icon-container {
width: 5rem;
height: 5rem;
background: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05);
}
.lock-icon { width: 2rem; height: 2rem; }
.lock-title { color: #1e293b; font-size: 1.5rem; font-weight: 700; margin: 0 0 1rem; }
.lock-text { color: #64748b; line-height: 1.6; margin: 0 0 2rem; max-width: 400px; }
.auth-button {
background: #1e293b;
color: #ffffff;
padding: 0.875rem 2rem;
border-radius: 0.75rem;
font-weight: 700;
text-decoration: none;
transition: all 0.3s ease;
}
.auth-button:hover { background: #0f172a; transform: translateY(-2px); }
</style>

View file

@ -1,17 +1,25 @@
---
import { blogPosts } from '@data/blogData';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
// Сортируем и форматируем для поиска
const searchData = posts
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.map(post => ({
title: post.data.title,
description: post.data.description,
slug: post.id,
category: post.data.category,
categoryColor: post.data.categoryColor,
date: post.data.date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
}));
const title = 'Поиск по статьям';
const searchData = blogPosts.map(post => ({
title: post.title,
description: post.description,
slug: post.slug,
category: post.category,
categoryColor: post.categoryColor,
date: post.date
}));
const postsJson = JSON.stringify(searchData);
---
@ -106,7 +114,7 @@ const postsJson = JSON.stringify(searchData);
// вывод динамического списка статей
resultsContainer.innerHTML = filtered.map(post => `
<a href="/blog/${post.slug}" class="search-result-item">
<a href="/blog/${post.id}" class="search-result-item">
<span class="result-date">${post.date}</span>
<h4 class="result-title">${post.title}</h4>
<p class="result-description">${post.description.substring(0, 120)}...</p>

View file

@ -32,7 +32,7 @@ const formatDate = (dateStr: string) => {
};
---
<article class="blog-card animate-on-scroll" data-animation="fade-up">
<article class="blog-card" data-animation="fade-up">
<a href={slug} class="card-link">
<!-- Изображение -->
<div class="card-image">

View file

@ -0,0 +1,346 @@
---
import AuthLockBlock from '@components/base/AuthLockBlock.astro';
interface Props {
postId: string;
isAuthorized?: boolean;
}
const { postId, isAuthorized = false } = Astro.props;
---
<section class="comment-section" data-post-id={postId}>
<h3 class="comment-title">Комментарии</h3>
{isAuthorized ? (
<form class="comment-form" id="comment-form" data-post-id={postId}>
<div class="form-group">
<label for="comment-text" class="form-label">Ваш комментарий *</label>
<textarea
id="comment-text"
name="comment"
class="form-textarea"
placeholder="Напишите ваш комментарий..."
rows="4"
required
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="btn-icon">
<path d="m22 2-7 20-4-9-9-4Z"></path>
<path d="M22 2 11 13"></path>
</svg>
Отправить комментарий
</button>
</div>
</form>
) : (
<AuthLockBlock
title="Авторизуйтесь, чтобы оставить комментарий"
description=""
buttonText="Войти в кабинет"
buttonHref="/auth/sign-in"
/>
)}
<!-- Список комментариев -->
<div class="comments-list" id="comments-list">
<!-- Пример комментарителя (заглушка для демонстрации) -->
<div class="comment-item">
<div class="comment-header">
<div class="comment-author">
<div class="author-avatar">А</div>
<div class="author-info">
<span class="author-name">Алексей Петров</span>
<span class="comment-date">2 апреля 2024</span>
</div>
</div>
</div>
<div class="comment-content">
<p>Спасибо за полезную статью! Столкнулся с похожей ситуацией, теперь знаю куда обращаться за помощью.</p>
</div>
<button class="comment-reply-btn">Ответить</button>
<!-- Ответ на комментарий -->
<div class="comment-reply">
<div class="comment-header">
<div class="comment-author">
<div class="author-avatar reply-avatar">Ю</div>
<div class="author-info">
<span class="author-name">Юрист АВ</span>
<span class="comment-date">3 апреля 2024</span>
</div>
</div>
</div>
<div class="comment-content">
<p>Алексей, спасибо за ваш комментарий! Обращайтесь — мы всегда готовы помочь в решении автоспоров.</p>
</div>
</div>
</div>
<!-- Ещё один комментарий -->
<div class="comment-item">
<div class="comment-header">
<div class="comment-author">
<div class="author-avatar" style="background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);">М</div>
<div class="author-info">
<span class="author-name">Мария Иванова</span>
<span class="comment-date">5 апреля 2024</span>
</div>
</div>
</div>
<div class="comment-content">
<p>Очень подробная инструкция! Подскажите, а если ДТП произошло без пострадавших, можно ли обойтись без вызова ГИБДД?</p>
</div>
</div>
</div>
</section>
<style>
.comment-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #f1f5f9;
}
.comment-title {
color: #1e293b;
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1.5rem;
}
.comment-form {
margin-bottom: 2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-label {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
}
.form-textarea {
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 1rem;
font-family: inherit;
resize: vertical;
min-height: 120px;
outline: none;
background: #f8fafc;
transition: all 0.2s ease;
}
.form-textarea:focus {
border-color: #d4af37;
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.1);
background: #ffffff;
}
.form-actions {
display: flex;
gap: 1rem;
}
.submit-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, #d4af37 0%, #eac26e 100%);
color: #1e293b;
border: none;
padding: 0.875rem 1.75rem;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
}
.btn-icon {
width: 1.125rem;
height: 1.125rem;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
}
/* Auth Lock Card - на всю ширину */
.comment-section :global(.auth-lock-card) {
max-width: 100% !important;
padding: 2rem 1.5rem !important;
margin-bottom: 2rem;
}
.comment-section :global(.lock-icon-container) {
width: 3rem !important;
height: 3rem !important;
margin-bottom: 1rem !important;
}
.comment-section :global(.lock-icon) {
width: 1.25rem !important;
height: 1.25rem !important;
}
.comment-section :global(.lock-title) {
font-size: 1.1rem !important;
margin-bottom: 0.75rem !important;
}
.comment-section :global(.lock-text) {
display: none !important;
}
.comment-section :global(.auth-button) {
padding: 0.6rem 1.5rem !important;
font-size: 0.9rem !important;
}
/* Comments List */
.comments-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.comment-item {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.25rem;
}
.comment-header {
margin-bottom: 0.75rem;
}
.comment-author {
display: flex;
align-items: center;
gap: 0.75rem;
}
.author-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: linear-gradient(135deg, #d4af37 0%, #eac26e 100%);
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
}
.reply-avatar {
background: linear-gradient(135deg, #22c55e 0%, #4ade80 100%) !important;
}
.author-info {
display: flex;
flex-direction: column;
}
.author-name {
color: #1e293b;
font-weight: 600;
font-size: 0.95rem;
}
.comment-date {
color: #94a3b8;
font-size: 0.8rem;
}
.comment-content {
color: #475569;
line-height: 1.6;
}
.comment-content p {
margin: 0;
}
.comment-reply-btn {
background: none;
border: none;
color: #d4af37;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0.5rem 0;
margin-top: 0.5rem;
transition: color 0.2s ease;
}
.comment-reply-btn:hover {
color: #b8942e;
text-decoration: underline;
}
/* Ответ на комментарий */
.comment-reply {
margin-top: 1rem;
margin-left: 2rem;
padding: 1rem 1.25rem;
background: #f8fafc;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
border-left: 3px solid #d4af37;
}
@media (max-width: 768px) {
.comment-title {
font-size: 1.25rem;
}
.submit-btn {
width: 100%;
justify-content: center;
}
.comment-reply {
margin-left: 1rem;
}
}
</style>
<script>
const setupCommentForm = () => {
const form = document.getElementById('comment-form') as HTMLFormElement;
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(form);
const comment = formData.get('comment');
if (comment) {
console.log('Новый комментарий:', comment);
alert('Спасибо! Ваш комментарий добавлен.');
form.reset();
// Здесь можно добавить логику для добавления комментарителя в список
}
});
}
};
setupCommentForm();
document.addEventListener('astro:page-load', setupCommentForm);
</script>

View file

@ -0,0 +1,139 @@
---
interface Props {
initialLikes?: number;
initialDislikes?: number;
postId: string;
}
const { initialLikes = 0, initialDislikes = 0, postId } = Astro.props;
---
<div class="post-reactions" data-post-id={postId}>
<button class="reaction-btn like-btn" data-action="like" aria-label="Нравится">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="reaction-icon">
<path d="M7 10v12"></path>
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"></path>
</svg>
<span class="reaction-count" data-count="likes">{initialLikes}</span>
</button>
<button class="reaction-btn dislike-btn" data-action="dislike" aria-label="Не нравится">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="reaction-icon">
<path d="M17 14V2"></path>
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"></path>
</svg>
<span class="reaction-count" data-count="dislikes">{initialDislikes}</span>
</button>
</div>
<style>
.post-reactions {
display: flex;
gap: 0.5rem;
}
.reaction-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.875rem;
font-weight: 600;
color: #ffffff;
backdrop-filter: blur(10px);
}
.reaction-icon {
width: 1rem;
height: 1rem;
transition: transform 0.2s ease;
}
.reaction-count {
min-width: 1rem;
text-align: center;
}
.like-btn:hover,
.like-btn.active {
background: #22c55e;
border-color: #22c55e;
transform: scale(1.05);
}
.dislike-btn:hover,
.dislike-btn.active {
background: #ef4444;
border-color: #ef4444;
transform: scale(1.05);
}
.reaction-btn:active {
transform: scale(0.95);
}
.reaction-btn:hover .reaction-icon {
transform: scale(1.2);
}
@media (max-width: 768px) {
.reaction-btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
}
</style>
<script>
document.querySelectorAll('.post-reactions').forEach((container) => {
const likeBtn = container.querySelector('.like-btn');
const dislikeBtn = container.querySelector('.dislike-btn');
const likesCount = container.querySelector('[data-count="likes"]');
const dislikesCount = container.querySelector('[data-count="dislikes"]');
let likes = parseInt(likesCount?.textContent || '0');
let dislikes = parseInt(dislikesCount?.textContent || '0');
let userAction: 'like' | 'dislike' | null = null;
likeBtn?.addEventListener('click', () => {
if (userAction === 'like') {
likes--;
userAction = null;
likeBtn.classList.remove('active');
} else {
if (userAction === 'dislike') {
dislikes--;
dislikeBtn.classList.remove('active');
}
likes++;
userAction = 'like';
likeBtn.classList.add('active');
}
if (likesCount) likesCount.textContent = likes.toString();
if (dislikesCount) dislikesCount.textContent = dislikes.toString();
});
dislikeBtn?.addEventListener('click', () => {
if (userAction === 'dislike') {
dislikes--;
userAction = null;
dislikeBtn.classList.remove('active');
} else {
if (userAction === 'like') {
likes--;
likeBtn.classList.remove('active');
}
dislikes++;
userAction = 'dislike';
dislikeBtn.classList.add('active');
}
if (likesCount) likesCount.textContent = likes.toString();
if (dislikesCount) dislikesCount.textContent = dislikes.toString();
});
});
</script>

View file

@ -0,0 +1,120 @@
---
interface Props {
title: string;
url: string;
className?: string;
}
const { title, url, className = '' } = Astro.props;
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
---
<div class={`post-social-share ${className}`}>
<span class="share-label">Поделиться:</span>
<div class="social-icons">
<!-- Telegram -->
<a
href={`https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="social-icon telegram"
aria-label="Поделиться в Telegram"
>
<svg xmlns="http://www.w3.org/2000/svg" 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>
<!-- VK -->
<a
href={`https://vk.com/share.php?url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="social-icon vk"
aria-label="Поделиться в VK"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.684 0H8.316C1.592 0 0 1.592 0 8.316v7.368C0 22.408 1.592 24 8.316 24h7.368C22.408 24 24 22.408 24 15.684V8.316C24 1.592 22.391 0 15.684 0zm3.692 17.123h-1.744c-.66 0-.864-.525-2.05-1.727-1.033-1-1.49-1.135-1.744-1.135-.356 0-.458.102-.458.593v1.575c0 .424-.135.678-1.253.678-1.846 0-3.896-1.118-5.335-3.202C4.624 10.857 4.03 8.57 4.03 8.096c0-.254.102-.491.593-.491h1.744c.44 0 .61.203.78.677.847 2.49 2.27 4.675 2.862 4.675.22 0 .322-.102.322-.66V9.721c-.068-1.186-.695-1.287-.695-1.71 0-.204.17-.407.44-.407h2.744c.373 0 .508.203.508.643v3.473c0 .372.17.508.271.508.22 0 .407-.136.813-.542 1.27-1.422 2.18-3.61 2.18-3.61.119-.254.322-.491.763-.491h1.744c.525 0 .644.27.525.643-.22 1.017-2.354 4.031-2.354 4.031-.186.305-.254.44 0 .78.186.254.796.779 1.203 1.253.745.847 1.32 1.558 1.473 2.05.17.49-.085.744-.576.744z"/>
</svg>
</a>
<!-- WhatsApp -->
<a
href={`https://api.whatsapp.com/send?text=${encodedTitle}%20${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="social-icon whatsapp"
aria-label="Поделиться в WhatsApp"
>
<svg xmlns="http://www.w3.org/2000/svg" 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 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-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 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.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 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/>
</svg>
</a>
<!-- Одноклассники -->
<a
href={`https://connect.ok.ru/offer?url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="social-icon odnoklassniki"
aria-label="Поделиться в Одноклассниках"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.505 17.043c.098.07.222.102.373.102.15 0 .274-.032.372-.102a.597.597 0 0 0 .214-.348.89.89 0 0 0 .064-.408 1.09 1.09 0 0 0-.136-.443c-.09-.153-.212-.277-.365-.373l-.01-.006c-.154-.097-.275-.222-.363-.373a.997.997 0 0 1-.136-.438.86.86 0 0 1 .063-.397.577.577 0 0 1 .204-.338.636.636 0 0 1 .372-.102c.15 0 .274.034.372.102a.577.577 0 0 1 .204.338.86.86 0 0 1 .063.397.997.997 0 0 1-.136.438c-.088.151-.209.276-.363.373l-.01.006c-.153.096-.275.22-.365.373a1.09 1.09 0 0 0-.136.443.89.89 0 0 0 .064.408.597.597 0 0 0 .214.348zM12 13.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm6.793-9.216A11.952 11.952 0 0 0 12 .5C5.373.5.008 5.865.008 12.5c0 2.768.929 5.372 2.534 7.458.065.085.136.165.208.244.016.018.034.034.05.051l.01.011c.135.147.277.288.425.421.043.039.087.077.131.114.129.111.263.216.4.315.076.056.154.109.233.161.116.077.235.149.357.217.095.054.191.105.29.151.115.054.234.102.355.145.097.034.195.065.295.091.12.032.241.058.365.079.097.016.195.03.293.039.131.012.264.017.398.017.135 0 .268-.005.4-.017.098-.009.196-.023.293-.039.124-.021.245-.047.365-.079.1-.026.198-.057.295-.091.121-.043.24-.091.355-.145.099-.046.195-.097.29-.151.122-.068.241-.14.357-.217.079-.052.157-.105.233-.161.137-.099.271-.204.4-.315.044-.037.088-.075.131-.114.148-.133.29-.274.425-.421l.01-.011c.016-.017.034-.033.05-.051.072-.079.143-.159.208-.244A11.953 11.953 0 0 0 23.992 12.5C23.992 5.865 18.627.5 12 .5z"/>
</svg>
</a>
</div>
</div>
<style>
.post-social-share {
display: flex;
align-items: center;
gap: 0.75rem;
}
.share-label {
color: #64748b;
font-size: 0.875rem;
font-weight: 600;
}
.social-icons {
display: flex;
gap: 0.5rem;
}
.social-icon {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.social-icon svg {
width: 1.125rem;
height: 1.125rem;
}
.social-icon.telegram { background: #0088cc; }
.social-icon.vk { background: #0077FF; }
.social-icon.whatsapp { background: #25D366; }
.social-icon.odnoklassniki { background: #EE8208; }
.social-icon:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
@media (max-width: 768px) {
.post-social-share {
flex-wrap: wrap;
}
}
</style>

View file

@ -0,0 +1,91 @@
---
import BlogCard from '@components/blog/BlogCard.astro';
interface CollectionEntry {
id: string;
data: {
title: string;
description: string;
category: string;
categoryColor: string;
date: Date;
readTime: string;
imageUrl: string;
};
}
interface Props {
posts: CollectionEntry[];
currentSlug?: string;
}
const { posts, currentSlug } = Astro.props;
// Форматируем дату
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
// Фильтруем текущую статью
const filteredPosts = currentSlug
? posts.filter(post => post.id !== currentSlug).slice(0, 3)
: posts.slice(0, 3);
---
<section class="related-posts">
<h3 class="section-title">Читайте также</h3>
<div class="related-grid">
{filteredPosts.map((post) => (
<BlogCard
title={post.data.title}
description={post.data.description}
category={post.data.category}
categoryColor={post.data.categoryColor}
date={formatDate(post.data.date)}
readTime={post.data.readTime}
imageUrl={post.data.imageUrl}
slug={`/blog/${post.id}`}
/>
))}
</div>
</section>
<style is:global>
.related-posts {
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #f1f5f9;
}
.section-title {
color: #1e293b;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 800;
margin: 0 0 2rem;
letter-spacing: -0.02em;
}
.related-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
@media (max-width: 1024px) {
.related-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.related-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
</style>

View file

@ -0,0 +1,20 @@
import { z } from 'astro/zod';
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
author: z.string().default('Юрист АВ'),
category: z.string(),
categoryColor: z.string().default('bg-gold'),
date: z.date(),
readTime: z.string(),
imageUrl: z.string(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };

View file

@ -0,0 +1,40 @@
---
title: "Спор с ГИБДД: как обжаловать штраф с камеры"
description: "Камеры фотофиксации часто ошибаются. Рассказываем, как правильно обжаловать штраф, полученные с автоматических комплексов."
author: "Юрист АВ"
category: "Штрафы"
categoryColor: "bg-green"
date: 2024-03-05
readTime: "7 мин"
imageUrl: "/images/blog/camera-fine.avif"
draft: false
---
## Введение
Автоматические камеры фиксации нарушений не всегда работают корректно. Ошибки могут быть связаны с неправильной настройкой, погодными условиями и другими факторами.
## Типичные ошибки камер
- Неверное определение номера автомобиля
- Фиксация нарушения на соседней полосе
- Ошибки в распознавании дорожных знаков
- Срабатывание на тени или отражении
## Порядок обжалования
Подать жалобу можно в течение **10 дней** через:
- Портал Госуслуг
- Лично в подразделении ГИБДД
- Через суд
**Необходимые документы:**
1. Копия постановления о штрафе
2. Доказательства вашей невиновности (видео, фото, свидетели)
3. Заявление с обоснованием жалобы
## Заключение
Не оплачивайте штрафы, с которыми не согласны. Обжалуйте их в установленный срок.

View file

@ -0,0 +1,42 @@
---
title: "Спор с автосалоном: как вернуть неисправный автомобиль"
description: "Права потребителя при покупке автомобиля с дефектами. Закон «О защите прав потребителей» и судебная практика в Сургуте."
author: "Юрист АВ"
category: "Автосалоны"
categoryColor: "bg-gold"
date: 2024-02-20
readTime: "11 мин"
imageUrl: "/images/blog/car-dealer-dispute.avif"
draft: false
---
## Введение
Покупка автомобиля — серьёзное вложение средств. Что делать, если купленная машина оказалась с дефектами?
## Права потребителя
По закону вы вправе:
- Вернуть автомобиль в течение 15 дней после покупки
- Потребовать соразмерного уменьшения цены
- Потребовать безвозмездного устранения недостатков
## Экспертиза качества
Проведение независимой экспертизы поможет подтвердить наличие дефектов и их стоимость устранения.
**Важно:** Автосалон обязан принять автомобиль на экспертизу за свой счёт, если гарантийный срок ещё не истёк.
## Судебная практика
Суды в большинстве случаев встают на сторону потребителей. Однако для успеха необходимо:
1. Сохранить все документы о покупке
2. Зафиксировать дефекты актом
3. Провести независимую экспертизу
4. Подать претензию в автосалон
## Заключение
Не бойтесь отстаивать свои права. Закон на стороне потребителя, а юристы помогут вам в этом.

View file

@ -0,0 +1,59 @@
---
title: "Что делать при ДТП: пошаговая инструкция 2024"
description: "Подробный разбор действий после дорожно-транспортного происшествия. Как оформить ДТП, какие документы собрать и куда обращаться за компенсацией."
author: "Юрист АВ"
category: "ДТП"
categoryColor: "bg-red"
date: 2024-03-20
readTime: "8 мин"
imageUrl: "/images/posts/2026/04/dtp-instruction.avif"
draft: false
---
## Введение
Дорожно-транспортное происшествие — это всегда стрессовая ситуация для любого водителя. Однако от правильности ваших действий в первые минуты после аварии зависит не только безопасность всех участников, но и успешность получения страховой выплаты.
## Первые действия после ДТП
Немедленно остановите автомобиль, включите аварийную сигнализацию и выставьте знак аварийной остановки. Не перемещайте предметы, имеющие отношение к ДТП, до прибытия сотрудников ГИБДД.
**Основные шаги:**
1. Остановитесь и не покидайте место происшествия
2. Включите аварийную сигнализацию
3. Выставьте знак аварийной остановки (15 м в населённом пункте, 30 м вне его)
4. Проверьте состояние всех участников ДТП
5. При необходимости вызовите скорую помощь
## Оформление документов
При оформлении ДТП по европротоколу (без вызова ГИБДД) необходимо заполнить извещение о ДТП. Оба участника должны подписать документ и сфотографировать место происшествия.
> **Важно:** Если второй участник ДТП предлагает разобраться на месте без оформления — не соглашайтесь. Это может привести к серьёзным проблемам в будущем.
### Когда вызов ГИБДД обязателен
- Если есть пострадавшие
- Если ущерб превышает 100 000 рублей
- Если у одного из участников нет ОСАГО
- Если возникли разногласия между участниками
## Обращение в страховую компанию
В течение 5 рабочих дней после ДТП обратитесь в свою страховую компанию с заявлением и полным пакетом документов. Задержка может стать основанием для отказа в выплате.
**Необходимые документы:**
- Заявление о страховой выплате
- Извещение о ДТП (европротокол) или справка о ДТП
- Копия протокола об административном правонарушении (если составлялся)
- Копия постановления по делу об административном правонарушении
- Паспорт транспортного средства
- Свидетельство о регистрации ТС
## Заключение
Своевременное и правильное оформление ДТП — залог успешного получения страховой выплаты. Если у вас возникли сложности, наши юристы готовы помочь вам разобраться в ситуации.
**Помните:** профессиональная юридическая помощь значительно повышает шансы на благоприятный исход дела.

View file

@ -0,0 +1,37 @@
---
title: "Независимая экспертиза после ДТП: зачем и когда нужна"
description: "Когда страховая занижает ущерб — поможет независимая оценка. Как выбрать эксперта, сколько стоит и как использовать в суде."
author: "Юрист АВ"
category: "ДТП"
categoryColor: "bg-red"
date: 2024-02-15
readTime: "6 мин"
imageUrl: "/images/blog/independent-expertise.avif"
draft: false
---
## Введение
Независимая экспертиза — важный инструмент для защиты ваших интересов при получении страховой выплаты.
## Когда нужна экспертиза
- Сумма выплаты существенно отличается от реального ущерба
- Страховая затягивает сроки рассмотрения
- Есть споры о степени повреждений
- Не учтены скрытые повреждения
## Выбор эксперта
Обращайтесь только к сертифицированным оценщикам, имеющим лицензию на данный вид деятельности.
**На что обратить внимание:**
1. Наличие сертификата и лицензии
2. Опыт работы (желательно от 3 лет)
3. Отзывы клиентов
4. Стоимость услуг
## Заключение
Независимая экспертиза — ваш козырь в споре со страховой компанией.

View file

@ -0,0 +1,53 @@
---
title: "Как оспорить лишение водительских прав"
description: "Разбираем основные основания для лишения прав и способы защиты в суде. Сроки обжалования, необходимые документы и типичные ошибки водителей."
author: "Юрист АВ"
category: "Лишение прав"
categoryColor: "bg-blue"
date: 2024-03-15
readTime: "12 мин"
imageUrl: "/images/blog/license-appeal.avif"
draft: false
---
## Введение
Лишение водительских прав — серьёзное наказание, которое может существенно осложнить жизнь. Однако не все водители знают, что решение суда можно обжаловать.
## Основания для лишения прав
Основные причины лишения:
- Управление в состоянии опьянения
- Отказ от медицинского освидетельствования
- Оставление места ДТП
- Повторные нарушения ПДД
- Выезд на встречную полосу
## Сроки обжалования
На обжалование решения суда даётся **10 дней** с момента вынесения постановления. Пропуск срока возможен только по уважительным причинам.
> **Важно:** Срок обжалования начинает течь с момента получения копии постановления. Если вы не получили документ — срок не начинается.
## Необходимые документы
Для успешного обжалования потребуются:
1. Копия постановления суда
2. Протокол об административном правонарушении
3. Показания свидетелей (письменные)
4. Видео- и фотоматериалы с места происшествия
5. Заключения экспертов (при необходимости)
## Типичные ошибки
Многие водители допускают следующие ошибки:
- **Пропуск срока обжалования** — не ждите до последнего дня
- **Отсутствие адвоката** — самостоятельная защита не всегда эффективна
- **Игнорирование судебныхных заседаний** — обязательно присутствуйте
## Заключение
Обжалование лишения прав — сложный процесс, требующий профессионального подхода. Обратитесь к специалистам для повышения шансов на успех.

View file

@ -0,0 +1,45 @@
---
title: "Возврат прав после лишения: новая процедура"
description: "Изменения в законодательстве 2024 года. Новый порядок возврата водительского удостоверения после окончания срока лишения."
author: "Юрист АВ"
category: "Лишение прав"
categoryColor: "bg-blue"
date: 2024-02-28
readTime: "9 мин"
imageUrl: "/images/blog/license-return.avif"
draft: false
---
## Введение
В 2024 году вступили в силу изменения в порядке возврата водительских прав после лишения.
## Что изменилось
Теперь для возврата прав необходимо:
- Повторно сдать теоретический экзамен по ПДД
- Оплатить все штрафы
- Предоставить медицинскую справку (для некоторых категорий нарушений)
## Медицинская справка
Для некоторых категорий нарушений требуется предоставление медицинской справки об отсутствии противопоказаний к управлению ТС.
**Когда нужна медсправка:**
- Лишение за управление в состоянии опьянения
- Отказ от медицинского освидетельствования
- Лишение за повторные нарушения
## Пошаговая инструкция
1. Дождитесь окончания срока лишения
2. Подготовьте необходимые документы
3. Сдайте экзамен по ПДД
4. Обратитесь в подразделение ГИБДД
5. Получите водительское удостоверение
## Заключение
Процедура возврата прав стала более регламентированной. Соблюдайте все требования для успешного получения удостоверения.

View file

@ -0,0 +1,34 @@
---
title: "Что делать, если виновник ДТП не имеет ОСАГО"
description: "Как получить компенсацию, если у виновника аварии нет полиса ОСАГО. Судебный иск, взыскание ущерба и практические советы юриста."
author: "Юрист АВ"
category: "ОСАГО"
categoryColor: "bg-gold"
date: 2024-02-08
readTime: "8 мин"
imageUrl: "/images/blog/no-osago.avif"
draft: false
---
## Введение
Ситуация, когда виновник не имеет полиса ОСАГО, встречается нередко. Разбираемся, как получить компенсацию.
## Возмещение через суд
Если у виновника нет ОСАГО, единственный способ получения компенсации — обращение в суд с иском к виновнику.
**Необходимые документы:**
1. Исковое заявление
2. Документы о ДТП (протокол, справка)
3. Заключение независимой экспертизы
4. Документы о стоимости ремонта
## Исполнительное производство
После решения суда можно обратиться к судебным приставам для принудительного взыскания.
## Заключение
Не оставляйте попыток получить компенсацию. Даже если у виновника нет ОСАГО, закон на вашей стороне.

View file

@ -0,0 +1,46 @@
---
title: "ОСАГО: как получить полную выплату от страховой"
description: "Почему страховые компании занижают выплаты и как добиться справедливой компенсации. Независимая экспертиза и судебная практика."
author: "Юрист АВ"
category: "ОСАГО"
categoryColor: "bg-gold"
date: 2024-03-10
readTime: "10 мин"
imageUrl: "/images/blog/osago-payout.avif"
draft: false
---
## Введение
Страховые компании часто занижают размер выплат по ОСАГО. Разбираемся, как добиться справедливой компенсации.
## Почему страховая занижает выплату
Основные причины:
- Использование минимальных коэффициентов износа
- Применение расценок не из вашего региона
- Игнорирование скрытых повреждений
- Неучтённые дополнительные расходы (эвакуация, хранение)
## Независимая экспертиза
Если вы не согласны с суммой выплаты, проведите независимую оценку ущерба. Это будет весомым аргументом при обращении в суд.
**Шаги для проведения экспертизы:**
1. Найдите сертифицированного эксперта-оценщика
2. Согласуйте дату и место осмотра
3. Уведомите страховую компанию о проведении экспертизы
4. Получите заключение эксперта
5. Подайте претензию в страховую компанию
> **Совет:** Расходы на независимую экспертизу можно взыскать со страховой компании через суд.
## Обращение в суд
Если страховая отказывается доплачивать, обращайтесь в суд. Практика показывает, что в большинстве случаев суды встают на сторону автовладельцев.
## Заключение
Не соглашайтесь на заниженные выплаты. Боритесь за свои права с помощью профессиональных юристов.

View file

@ -0,0 +1,31 @@
---
title: "Обжалование протокола ГИБДД: типичные ошибки инспекторов"
description: "Какие нарушения допускают сотрудники ГИБДД при составлении протокола и как использовать это в свою пользу при обжаловании."
author: "Юрист АВ"
category: "Штрафы"
categoryColor: "bg-green"
date: 2024-02-01
readTime: "10 мин"
imageUrl: "/images/blog/protocol-errors.avif"
draft: false
---
## Введение
Протокол ГИБДД может быть признан недействительным при наличии существенных нарушений в порядке его составления.
## Типичные нарушения
- Отсутствие понятых
- Неправильное указание данных
- Отсутствие схемы ДТП
- Нарушения при фотофиксации
- Протокол составлен не тем должностным лицом
## Как обжаловать
Подайте жалобу в вышестоящий орган или суд в течение **10 дней** с момента получения копии протокола.
## Заключение
Знание своих прав и процедур обжалования — ключ к успешной защите интересов.

View file

@ -1,105 +0,0 @@
export interface BlogPost {
title: string;
description: string;
category: string;
categoryColor: string;
date: string;
readTime: string;
imageUrl: string;
slug: string;
}
export const blogPosts: BlogPost[] = [
{
title: "Что делать при ДТП: пошаговая инструкция 2024",
description: "Подробный разбор действий после дорожно-транспортного происшествия. Как оформить ДТП, какие документы собрать и куда обращаться за компенсацией.",
category: "ДТП",
categoryColor: "bg-red",
date: "2024-03-20",
readTime: "8 мин",
imageUrl: "/images/blog/dtp-instruction.avif",
slug: "/blog/dtp-instruction-2024"
},
{
title: "Как оспорить лишение водительских прав",
description: "Разбираем основные основания для лишения прав и способы защиты в суде. Сроки обжалования, необходимые документы и типичные ошибки водителей.",
category: "Лишение прав",
categoryColor: "bg-blue",
date: "2024-03-15",
readTime: "12 мин",
imageUrl: "/images/blog/license-appeal.avif",
slug: "/blog/license-appeal"
},
{
title: "ОСАГО: как получить полную выплату от страховой",
description: "Почему страховые компании занижают выплаты и как добиться справедливой компенсации. Независимая экспертиза и судебная практика.",
category: "ОСАГО",
categoryColor: "bg-gold",
date: "2024-03-10",
readTime: "10 мин",
imageUrl: "/images/blog/osago-payout.avif",
slug: "/blog/osago-full-payout"
},
{
title: "Спор с ГИБДД: как обжаловать штраф с камеры",
description: "Камеры фотофиксации часто ошибаются. Рассказываем, как правильно обжаловать штраф, полученные с автоматических комплексов.",
category: "Штрафы",
categoryColor: "bg-green",
date: "2024-03-05",
readTime: "7 мин",
imageUrl: "/images/blog/camera-fine.avif",
slug: "/blog/camera-fine-appeal"
},
{
title: "Возврат прав после лишения: новая процедура",
description: "Изменения в законодательстве 2024 года. Новый порядок возврата водительского удостоверения после окончания срока лишения.",
category: "Лишение прав",
categoryColor: "bg-blue",
date: "2024-02-28",
readTime: "9 мин",
imageUrl: "/images/blog/license-return.avif",
slug: "/blog/license-return-2024"
},
{
title: "Спор с автосалоном: как вернуть неисправный автомобиль",
description: "Права потребителя при покупке автомобиля с дефектами. Закон «О защите прав потребителей» и судебная практика в Сургуте.",
category: "Автосалоны",
categoryColor: "bg-gold",
date: "2024-02-20",
readTime: "11 мин",
imageUrl: "/images/blog/car-dealer-dispute.avif",
slug: "/blog/car-dealer-dispute"
},
{
title: "Независимая экспертиза после ДТП: зачем и когда нужна",
description: "Когда страховая занижает ущерб — поможет независимая оценка. Как выбрать эксперта, сколько стоит и как использовать в суде.",
category: "ДТП",
categoryColor: "bg-red",
date: "2024-02-15",
readTime: "6 мин",
imageUrl: "/images/blog/independent-expertise.avif",
slug: "/blog/independent-expertise"
},
{
title: "Что делать, если виновник ДТП не имеет ОСАГО",
description: "Как получить компенсацию, если у виновника аварии нет полиса ОСАГО. Судебный иск, взыскание ущерба и практические советы юриста.",
category: "ОСАГО",
categoryColor: "bg-gold",
date: "2024-02-08",
readTime: "8 мин",
imageUrl: "/images/blog/no-osago.avif",
slug: "/blog/no-osago-at-fault"
},
{
title: "Обжалование протокола ГИБДД: типичные ошибки инспекторов",
description: "Какие нарушения допускают сотрудники ГИБДД при составлении протокола и как использовать это в свою пользу при обжаловании.",
category: "Штрафы",
categoryColor: "bg-green",
date: "2024-02-01",
readTime: "10 мин",
imageUrl: "/images/blog/protocol-errors.avif",
slug: "/blog/protocol-errors"
}
];
export const categories = ['Все', 'ДТП', 'ОСАГО', 'Лишение прав', 'Штрафы', 'Автосалоны'];

View file

@ -0,0 +1,344 @@
---
import "@styles/global.css";
import { SITE_TITLE_SUFFIX } from "@constants";
import Header from "@components/layout/header/Header.astro";
import Footer from "@components/layout/footer/Footer.astro";
import Breadcrumbs from "@components/base/Breadcrumbs.astro";
import ConsultationModal from "@components/base/ConsultationModal.astro";
import PostSocialShare from "@components/blog/PostSocialShare.astro";
import PostReactionButtons from "@components/blog/PostReactionButtons.astro";
export interface Props {
title: string;
description: string;
canonicalLink?: string;
breadcrumbs?: Array<{ label: string; href?: string }>;
heroImage: string;
heroAlt: string;
category: string;
postTitle: string;
date: string;
author: string;
readTime: string;
postId: string;
postUrl: string;
initialLikes?: number;
initialDislikes?: number;
}
const {
title,
description,
canonicalLink,
breadcrumbs,
heroImage,
heroAlt,
category,
postTitle,
date,
author,
readTime,
postId,
postUrl,
initialLikes = 0,
initialDislikes = 0
} = Astro.props;
---
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="icon" href="/favicons/favicon.ico" />
<title>{title} {SITE_TITLE_SUFFIX}</title>
<meta name="description" content={description} />
{canonicalLink && <link rel="canonical" href={canonicalLink} />}
<meta name="yandex-verification" content="be3edfd138348e43" />
</head>
<body>
<Header />
<main class="main-content">
{breadcrumbs && breadcrumbs.length > 0 && (
<div class="breadcrumbs-wrapper">
<Breadcrumbs items={breadcrumbs} />
</div>
)}
<!-- Hero с полноширинным изображением -->
<section class="article-hero">
<div class="article-hero-image">
<img src={heroImage} alt={heroAlt} />
<div class="article-hero-overlay"></div>
</div>
<div class="article-hero-content">
<div class="site-container">
<div class="article-hero-inner">
<div class="article-hero-left">
<span class="article-category-badge">{category}</span>
<h1 class="article-title">{postTitle}</h1>
<div class="article-meta">
<span class="meta-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect>
<line x1="16" x2="16" y1="2" y2="6"></line>
<line x1="8" x2="8" y1="2" y2="6"></line>
<line x1="3" x2="21" y1="10" y2="10"></line>
</svg>
{date}
</span>
<span class="meta-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span class="meta-author">{author}</span>
</span>
<span class="meta-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{readTime}
</span>
</div>
</div>
<div class="article-hero-right">
<div class="article-actions">
<PostReactionButtons
postId={postId}
initialLikes={initialLikes}
initialDislikes={initialDislikes}
/>
<PostSocialShare
title={postTitle}
url={postUrl}
/>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Основной контент -->
<div class="article-body">
<div class="site-container">
<div class="article-content-wrapper">
<slot />
</div>
</div>
</div>
</main>
<Footer />
<ConsultationModal />
</body>
</html>
<style>
.main-content {
padding-top: 0;
}
.breadcrumbs-wrapper {
padding-top: 4.75rem;
background: #f8fafc;
border-bottom: 1px solid rgba(30, 48, 80, 0.05);
}
/* Article Hero */
.article-hero {
position: relative;
width: 100%;
min-height: 500px;
display: flex;
align-items: flex-end;
}
.article-hero-image {
position: absolute;
inset: 0;
z-index: 0;
}
.article-hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.article-hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(10, 37, 64, 0.3) 0%, rgba(10, 37, 64, 0.85) 100%);
}
.article-hero-content {
position: relative;
z-index: 2;
width: 100%;
padding: 3rem 0;
}
.article-hero-inner {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 2rem;
flex-wrap: wrap;
}
.article-hero-left {
flex: 1;
min-width: 0;
}
.article-category-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
background: linear-gradient(135deg, #d4af37 0%, #eac26e 100%);
color: #1e293b;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.article-title {
color: #ffffff;
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 800;
margin: 0 0 1.5rem;
letter-spacing: -0.02em;
line-height: 1.2;
}
.article-meta {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
font-weight: 500;
}
.meta-icon {
width: 1rem;
height: 1rem;
}
.meta-author {
color: #eac26e;
font-weight: 600;
}
.article-hero-right {
flex-shrink: 0;
}
.article-actions {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
/* Article Body */
.article-body {
padding: 3rem 0;
background: #f8fafc;
}
.article-content-wrapper {
max-width: 1200px;
margin: 0 auto;
background: #ffffff;
border-radius: 1.5rem;
padding: 3rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.article-content-wrapper > :last-child {
margin-bottom: 0;
}
@media (max-width: 1024px) {
.article-hero-inner {
flex-direction: column;
align-items: flex-start;
}
.article-hero-right {
align-self: flex-start;
}
}
@media (max-width: 768px) {
.article-hero {
min-height: 400px;
}
.article-hero-content {
padding: 2rem 0;
}
.article-title {
font-size: 1.75rem;
}
.article-meta {
flex-direction: column;
gap: 0.75rem;
}
.article-actions {
flex-direction: column;
align-items: flex-start;
}
.article-content-wrapper {
padding: 2rem;
}
}
</style>
<script>
// Клиентский скрипт для открытия модального окна
document.addEventListener("DOMContentLoaded", () => {
const btn = document.getElementById("consultation-btn");
btn?.addEventListener("click", () => {
window.dispatchEvent(
new CustomEvent("open-modal", {
detail: "consultation-modal",
}),
);
});
});
// Для Astro View Transitions
document.addEventListener("astro:page-load", () => {
const btn = document.getElementById("consultation-btn");
btn?.addEventListener("click", () => {
window.dispatchEvent(
new CustomEvent("open-modal", {
detail: "consultation-modal",
}),
);
});
});
</script>

View file

@ -0,0 +1,168 @@
---
import ArticleLayout from '@layouts/ArticleLayout.astro';
import { SITE_URL } from '@constants';
import PostCommentForm from '@components/blog/PostCommentForm.astro';
import RelatedPosts from '@components/blog/RelatedPosts.astro';
import { getCollection, getEntry, render } from 'astro:content';
export const prerender = false;
export async function getStaticPaths() {
const posts = await getCollection('blog') as { id: string; data: Record<string, any> }[];
return posts.map((post: { id: string }) => ({
params: { slug: post.id },
}));
}
const slug = Astro.params.slug;
if (!slug) {
return Astro.redirect('/blog');
}
const post = await getEntry('blog', slug);
if (!post) {
return Astro.redirect('/blog');
}
const { Content } = await render(post);
// Форматируем дату
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
// Логика авторизации (пока статичная переменная)
const isAuthorized = false;
// URL текущей страницы
const currentUrl = `${SITE_URL}/blog/${post.id}`;
// Получаем все посты для блока "Читайте также"
const allPosts = await getCollection('blog');
---
<ArticleLayout
title={`${post.data.title} — автоюрист в Сургуте`}
description={post.data.description}
canonicalLink={`${SITE_URL}/blog/${post.id}`}
breadcrumbs={[
{ label: 'Главная', href: '/' },
{ label: 'Блог', href: '/blog' },
{ label: post.data.title }
]}
heroImage={post.data.imageUrl}
heroAlt={post.data.title}
category={post.data.category}
postTitle={post.data.title}
date={formatDate(post.data.date)}
author={post.data.author}
readTime={post.data.readTime}
postId={post.id}
postUrl={currentUrl}
initialLikes={12}
initialDislikes={2}
>
<!-- Содержимое статьи -->
<div class="post-content">
<Content />
</div>
<!-- Форма комментариев -->
<PostCommentForm
postId={post.id}
isAuthorized={isAuthorized}
/>
<!-- Похожие статьи -->
<RelatedPosts
posts={allPosts}
currentSlug={post.id}
/>
</ArticleLayout>
<style>
/* Post Content */
.post-content {
padding: 3rem;
color: #334155;
line-height: 1.8;
max-width: 1200px;
margin: 0 auto;
}
.post-content :global(h2) {
color: #1e293b;
font-size: 1.75rem;
font-weight: 700;
margin: 2rem 0 1rem;
letter-spacing: -0.02em;
}
.post-content :global(h3) {
color: #1e293b;
font-size: 1.375rem;
font-weight: 700;
margin: 1.75rem 0 1rem;
}
.post-content :global(p) {
margin: 0 0 1.25rem;
font-size: 1.05rem;
}
.post-content :global(ul), .post-content :global(ol) {
margin: 1rem 0;
padding-left: 1.5rem;
}
.post-content :global(li) {
margin: 0.5rem 0;
font-size: 1.05rem;
}
.post-content :global(blockquote) {
margin: 2rem 0;
padding: 1.5rem 2rem;
background: #f8fafc;
border-left: 4px solid #d4af37;
border-radius: 0 0.75rem 0.75rem 0;
font-style: italic;
}
.post-content :global(blockquote p) {
margin: 0;
color: #475569;
}
.post-content :global(strong) {
color: #1e293b;
font-weight: 700;
}
.post-content :global(code) {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
@media (max-width: 768px) {
.post-content {
padding: 2rem;
}
.post-content :global(h2) {
font-size: 1.5rem;
}
.post-content :global(h3) {
font-size: 1.25rem;
}
}
</style>

View file

@ -4,19 +4,35 @@ import { SITE_URL } from '@constants';
import PageHero from '@components/base/PageHero.astro';
import BlogCategories from '@components/blog/BlogCategories.astro';
import BlogCard from '@components/blog/BlogCard.astro';
import BlogPagination from '@components/blog/BlogPagination.astro';
import Pagination from '@components/base/Pagination.astro';
import CTA from '@components/base/CTA.astro';
import SearchModal from '@components/base/SearchModal.astro';
import { blogPosts, categories } from '@data/blogData';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
// Сортируем посты по дате (новые сверху)
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const POSTS_PER_PAGE = 6;
const currentPage = 1;
const totalPages = Math.ceil(blogPosts.length / POSTS_PER_PAGE);
const totalPages = Math.ceil(sortedPosts.length / POSTS_PER_PAGE);
const startIndex = 0;
const endIndex = POSTS_PER_PAGE;
const paginatedPosts = blogPosts.slice(startIndex, endIndex);
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
// Форматируем дату
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
// Категории
const categories = ['Все', ...new Set(posts.map((post: any) => post.data.category))];
---
<Layout
@ -49,17 +65,17 @@ const paginatedPosts = blogPosts.slice(startIndex, endIndex);
<section class="blog-grid-section">
<div class="site-container">
<div class="blog-grid" id="blog-grid">
{paginatedPosts.map((post) => (
<article class="blog-card-wrapper" data-category={post.category}>
{paginatedPosts.map((post: any) => (
<article class="blog-card-wrapper" data-category={post.data.category}>
<BlogCard
title={post.title}
description={post.description}
category={post.category}
categoryColor={post.categoryColor}
date={post.date}
readTime={post.readTime}
imageUrl={post.imageUrl}
slug={post.slug}
title={post.data.title}
description={post.data.description}
category={post.data.category}
categoryColor={post.data.categoryColor}
date={formatDate(post.data.date)}
readTime={post.data.readTime}
imageUrl={post.data.imageUrl}
slug={`/blog/${post.id}`}
/>
</article>
))}

View file

@ -4,19 +4,35 @@ import { SITE_URL } from '@constants';
import PageHero from '@components/base/PageHero.astro';
import BlogCategories from '@components/blog/BlogCategories.astro';
import BlogCard from '@components/blog/BlogCard.astro';
import BlogPagination from '@components/blog/BlogPagination.astro';
import Pagination from '@components/base/Pagination.astro';
import CTA from '@components/base/CTA.astro';
import SearchModal from '@components/base/SearchModal.astro';
import { blogPosts, categories } from '@data/blogData';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
// Сортируем посты по дате (новые сверху)
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const POSTS_PER_PAGE = 6;
const currentPage = Number(Astro.params.page) || 1;
const totalPages = Math.ceil(blogPosts.length / POSTS_PER_PAGE);
const totalPages = Math.ceil(sortedPosts.length / POSTS_PER_PAGE);
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
const paginatedPosts = blogPosts.slice(startIndex, endIndex);
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
// Форматируем дату
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
// Категории
const categories = ['Все', ...new Set(posts.map(post => post.data.category))];
---
<Layout
@ -50,17 +66,17 @@ const paginatedPosts = blogPosts.slice(startIndex, endIndex);
<section class="blog-grid-section">
<div class="site-container">
<div class="blog-grid" id="blog-grid">
{paginatedPosts.map((post) => (
<article class="blog-card-wrapper" data-category={post.category}>
{paginatedPosts.map((post: any) => (
<article class="blog-card-wrapper" data-category={post.data.category}>
<BlogCard
title={post.title}
description={post.description}
category={post.category}
categoryColor={post.categoryColor}
date={post.date}
readTime={post.readTime}
imageUrl={post.imageUrl}
slug={post.slug}
title={post.data.title}
description={post.data.description}
category={post.data.category}
categoryColor={post.data.categoryColor}
date={formatDate(post.data.date)}
readTime={post.data.readTime}
imageUrl={post.data.imageUrl}
slug={`/blog/${post.id}`}
/>
</article>
))}

View file

@ -3,7 +3,9 @@ import Layout from '@layouts/Layout.astro';
import { SITE_URL } from '@constants';
import BlogCard from '@components/blog/BlogCard.astro';
import SearchModal from '@components/base/SearchModal.astro';
import { blogPosts } from '@data/blogData';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
// Получаем параметр поиска из URL
const url = new URL(Astro.request.url);
@ -16,21 +18,30 @@ const breadcrumbsItems = [
];
// Функция поиска по статьям
function searchArticles(query: string) {
function searchArticles(query: string, allPosts: typeof posts) {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
return blogPosts.filter(post => {
const titleMatch = post.title.toLowerCase().includes(lowerQuery);
const descriptionMatch = post.description.toLowerCase().includes(lowerQuery);
const categoryMatch = post.category.toLowerCase().includes(lowerQuery);
return allPosts.filter(post => {
const titleMatch = post.data.title.toLowerCase().includes(lowerQuery);
const descriptionMatch = post.data.description.toLowerCase().includes(lowerQuery);
const categoryMatch = post.data.category.toLowerCase().includes(lowerQuery);
return titleMatch || descriptionMatch || categoryMatch;
});
}
const searchResults = searchArticles(searchQuery);
const searchResults = searchArticles(searchQuery, posts);
// Форматируем дату
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
---
<Layout
@ -46,20 +57,20 @@ const searchResults = searchArticles(searchQuery);
<h1 class="search-title">
{searchQuery ? `Результаты поиска` : 'Поиск статей'}
</h1>
{searchQuery && (
<div class="search-info">
<p class="search-query-text">
По запросу: <span class="query-highlight">"{searchQuery}"</span>
</p>
<p class="search-count">
{searchResults.length === 0
? 'Ничего не найдено'
{searchResults.length === 0
? 'Ничего не найдено'
: `Найдено статей: ${searchResults.length}`}
</p>
</div>
)}
<button class="open-search-btn" id="open-search-btn" data-modal-target="search-modal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
@ -76,16 +87,16 @@ const searchResults = searchArticles(searchQuery);
<section class="results-section">
<div class="site-container">
<div class="results-grid">
{searchResults.map((post) => (
{searchResults.map((post: any) => (
<BlogCard
title={post.title}
description={post.description}
category={post.category}
categoryColor={post.categoryColor}
date={post.date}
readTime={post.readTime}
imageUrl={post.imageUrl}
slug={post.slug}
title={post.data.title}
description={post.data.description}
category={post.data.category}
categoryColor={post.data.categoryColor}
date={formatDate(post.data.date)}
readTime={post.data.readTime}
imageUrl={post.data.imageUrl}
slug={`/blog/${post.id}`}
/>
))}
</div>
@ -131,10 +142,10 @@ const searchResults = searchArticles(searchQuery);
<script>
(function() {
const btn = document.getElementById('open-search-btn');
btn?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('open-modal', {
detail: 'search-modal'
window.dispatchEvent(new CustomEvent('open-modal', {
detail: 'search-modal'
}));
});
@ -142,11 +153,11 @@ const searchResults = searchArticles(searchQuery);
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (!query && btn) {
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-modal', {
detail: 'search-modal'
window.dispatchEvent(new CustomEvent('open-modal', {
detail: 'search-modal'
}));
}, 300);
}

View file

@ -2,7 +2,7 @@
import Layout from '@layouts/Layout.astro';
import { SITE_URL, COMPANY } from '@constants';
import PageHero from '@components/base/PageHero.astro';
import CTA from '@components/base/CTA.astro';
import AuthLockBlock from '@components/base/AuthLockBlock.astro';
// Логика авторизации (пока статичная переменная)
const isAuthorized = false; // Измените на true, чтобы увидеть форму
@ -23,13 +23,13 @@ const isAuthorized = false; // Измените на true, чтобы увиде
titleGold="с нами"
description="Мы всегда на связи и готовы помочь вам в решении автоспоров. Оставьте заявку или позвоните — первая консультация бесплатно."
layout="with-image"
sideImage="/images/home/avtourist-surgut.avif"
sideImage="/images/contacts/conImg.avif"
sideImageAlt="Автоюрист Сургут"
experienceBadge={{
number: "15",
text: "МИНУТ НА СВЯЗИ"
}}
bgImage="/images/home/bg_hero.avif"
bgImage="/images/contacts/conBg.avif"
/>
<!-- Карточки контактов -->
@ -159,28 +159,16 @@ const isAuthorized = false; // Измените на true, чтобы увиде
</p>
</form>
) : (
<div class="auth-lock-card animate-on-scroll" data-animation="fade-up" data-delay="200">
<div class="lock-icon-container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lock-icon">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="lock-title">Форма доступна только клиентам</h3>
<p class="lock-text">Чтобы отправить сообщение напрямую юристу, пожалуйста, авторизуйтесь в личном кабинете.</p>
<a href="/auth/sign-in" class="auth-button">Войти в кабинет</a>
</div>
<AuthLockBlock
title="Авторизуйтесь, чтобы написать"
description="Чтобы отправить сообщение напрямую юристу, пожалуйста, войдите в личный кабинет."
buttonText="Войти в кабинет"
buttonHref="/auth/sign-in"
className="animate-on-scroll"
/>
)}
</div>
</section>
<!-- CTA-блок -->
<CTA
icon="phone"
title="Нужна срочная помощь?"
description="Запишитесь на бесплатную консультацию прямо сейчас — мы перезвоним в течение 15 минут"
btnText="Записаться на консультацию"
/>
</Layout>
<style>
@ -247,7 +235,7 @@ const isAuthorized = false; // Измените на true, чтобы увиде
.hours-icon { width: 1rem; height: 1rem; }
/* ===== FORM SECTION & AUTH LOCK ===== */
/* ===== FORM SECTION ===== */
.contact-form-section {
padding: 5rem 0;
background: #ffffff;
@ -257,49 +245,6 @@ const isAuthorized = false; // Измените на true, чтобы увиде
.section-title { font-size: clamp(2rem, 4vw, 2.5rem); font-weight: 800; color: #1e293b; margin: 0 0 1rem; }
.section-description { color: #64748b; font-size: 1.1rem; margin: 0; }
/* Auth Lock Card */
.auth-lock-card {
max-width: 600px;
margin: 0 auto;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 1.5rem;
padding: 4rem 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.lock-icon-container {
width: 5rem;
height: 5rem;
background: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05);
}
.lock-icon { width: 2rem; height: 2rem; }
.lock-title { color: #1e293b; font-size: 1.5rem; font-weight: 700; margin: 0 0 1rem; }
.lock-text { color: #64748b; line-height: 1.6; margin: 0 0 2rem; max-width: 400px; }
.auth-button {
background: #1e293b;
color: #ffffff;
padding: 0.875rem 2rem;
border-radius: 0.75rem;
font-weight: 700;
text-decoration: none;
transition: all 0.3s ease;
}
.auth-button:hover { background: #0f172a; transform: translateY(-2px); }
/* Contact Form Styles */
.contact-form {
max-width: 700px;
@ -359,7 +304,6 @@ const isAuthorized = false; // Измените на true, чтобы увиде
@media (max-width: 768px) {
.cards-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.auth-lock-card { padding: 3rem 1.5rem; }
}
</style>

View file

@ -2,12 +2,15 @@
import Layout from '@layouts/Layout.astro';
import { SITE_URL } from '@constants';
import PageHero from '@components/base/PageHero.astro';
import CTA from '@components/base/CTA.astro';
import Pagination from '@components/base/Pagination.astro';
import ReviewCard from '@components/reviews/ReviewCard.astro';
import VotingSummary from '@components/reviews/VotingSummary.astro';
import AuthLockBlock from '@components/base/AuthLockBlock.astro';
import { reviewsData, votingSummary } from '@data/reviewsData';
// Логика авторизации (пока статичная переменная)
const isAuthorized = true; // Измените на true, чтобы увидеть форму
const REVIEWS_PER_PAGE = 6;
const currentPage = 1;
const totalPages = Math.ceil(reviewsData.length / REVIEWS_PER_PAGE);
@ -32,13 +35,13 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex);
titleGold="водителей из Сургута"
description="Узнайте, как мы помогли нашим клиентам решить их проблемы с автоспорами"
layout="with-image"
sideImage="/images/home/avtourist-surgut.avif"
sideImage="/images/reviews/revImg.avif"
sideImageAlt="Автоюрист Сургут"
experienceBadge={{
number: "95%",
text: "ДОВОЛЬНЫХ КЛИЕНТОВ"
}}
bgImage="/images/home/bg_hero.avif"
bgImage="/images/reviews/revBg.avif"
/>
<section class="reviews-page">
@ -78,14 +81,94 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex);
/>
)}
<!-- CTA блок -->
<CTA
icon="chat"
title="Хотите оставить отзыв?"
description="Поделитесь своим опытом работы с нами. Ваш отзыв поможет другим водителям принять правильное решение."
btnText="Оставить отзыв"
btnHref="/contacts"
/>
<!-- Форма для отзыва -->
<section class="review-form-section">
<div class="site-container">
<div class="form-header">
<h2 class="section-title animate-on-scroll" data-animation="fade-up">
Оставьте <span class="text-gold">отзыв</span>
</h2>
<p class="section-description animate-on-scroll" data-animation="fade-up" data-delay="100">
Поделитесь своим опытом — ваше мнение важно для нас
</p>
</div>
{isAuthorized ? (
<form class="review-form animate-on-scroll" data-animation="fade-up" data-delay="200" action="#" method="POST" id="review-form">
<div class="form-row">
<div class="form-group">
<label for="review-name" class="form-label">Имя *</label>
<input
type="text"
id="review-name"
name="name"
class="form-input"
placeholder="Иван"
required
/>
</div>
<div class="form-group">
<label for="review-surname" class="form-label">Фамилия *</label>
<input
type="text"
id="review-surname"
name="surname"
class="form-input"
placeholder="Иванов"
required
/>
</div>
</div>
<div class="form-group">
<label for="review-rating" class="form-label">Оценка *</label>
<select
id="review-rating"
name="rating"
class="form-input"
required
>
<option value="">Выберите оценку</option>
<option value="5">5 — Отлично</option>
<option value="4">4 — Хорошо</option>
<option value="3">3 — Удовлетворительно</option>
<option value="2">2 — Плохо</option>
<option value="1">1 — Очень плохо</option>
</select>
</div>
<div class="form-group">
<label for="review-text" class="form-label">Ваш отзыв *</label>
<textarea
id="review-text"
name="review"
class="form-textarea"
placeholder="Расскажите о вашем опыте работы с нами..."
rows="5"
required
></textarea>
</div>
<button type="submit" class="submit-btn">
Отправить отзыв
</button>
<p class="form-privacy">
Нажимая кнопку, вы соглашаетесь с
<a href="/privacy" class="privacy-link">политикой конфиденциальности</a>
</p>
</form>
) : (
<AuthLockBlock
title="Авторизуйтесь, чтобы оставить отзыв"
description="Чтобы поделиться своим опытом, пожалуйста, войдите в личный кабинет."
buttonText="Войти в кабинет"
buttonHref="/auth/sign-in"
className="animate-on-scroll"
/>
)}
</div>
</section>
</div>
</section>
</Layout>
@ -104,48 +187,64 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex);
margin: 3rem 0;
}
/* CTA блок */
.cta-section {
margin-top: 4rem;
padding: 3rem;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-radius: 1.5rem;
text-align: center;
/* ===== REVIEW FORM SECTION ===== */
.review-form-section {
padding: 5rem 0;
background: #ffffff;
}
.cta-title {
color: #ffffff;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 800;
margin: 0 0 1rem 0;
letter-spacing: -0.02em;
.form-header { text-align: center; margin-bottom: 3rem; }
.section-title { font-size: clamp(2rem, 4vw, 2.5rem); font-weight: 800; color: #1e293b; margin: 0 0 1rem; }
.section-description { color: #64748b; font-size: 1.1rem; margin: 0; }
/* Review Form Styles */
.review-form {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.cta-text {
color: #cbd5e1;
font-size: 1.125rem;
max-width: 600px;
margin: 0 auto 2rem;
line-height: 1.6;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.form-label { font-size: 0.9rem; font-weight: 600; color: #1e293b; }
.cta-button {
display: inline-block;
padding: 1rem 2.5rem;
background: linear-gradient(135deg, #d4af37 0%, #fbbf24 100%);
color: #1e293b;
font-weight: 700;
font-size: 1.125rem;
text-decoration: none;
.form-input, .form-textarea {
padding: 0.875rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s ease;
outline: none;
background: #f8fafc;
}
.form-input:focus, .form-textarea:focus {
border-color: #d4af37;
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.1);
background: #ffffff;
}
.form-textarea { resize: vertical; min-height: 140px; }
.submit-btn {
background: linear-gradient(135deg, #d4af37 0%, #eac26e 100%);
color: #1e293b;
border: none;
padding: 1rem 2rem;
border-radius: 0.75rem;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
}
.submit-btn:hover { transform: translateY(-3px); box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4); }
.form-privacy { font-size: 0.8rem; color: #94a3b8; text-align: center; }
.privacy-link { color: #d4af37; text-decoration: underline; }
/* Анимации */
.animate-on-scroll {
@ -178,9 +277,7 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex);
gap: 1.5rem;
}
.cta-section {
padding: 2rem;
}
.form-row { grid-template-columns: 1fr; }
}
</style>
@ -213,11 +310,27 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex);
});
};
// Обработка формы отзыва
const setupReviewForm = () => {
const form = document.getElementById('review-form') as HTMLFormElement;
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(form);
console.log('Отправка отзыва:', Object.fromEntries(formData));
alert('Спасибо! Ваш отзыв отправлен на модерацию.');
form.reset();
});
}
};
// Запуск
setupAnimations();
setupReviewForm();
// Для поддержки View Transitions в Astro
document.addEventListener('astro:after-swap', () => {
setupAnimations();
setupReviewForm();
});
</script>