astro_avtourist/frontend/src/components/reviews/ReviewCard.astro

573 lines
No EOL
14 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
type ColorKey = 'bg-gradient-1' | 'bg-gradient-2' | 'bg-gradient-3' | 'bg-gradient-4' | 'bg-gradient-5' | 'bg-gradient-6';
export interface Props {
name: string;
profession: string;
text: string;
rating: number;
initial: string;
color: ColorKey;
date: string;
reviewId: string;
initialLikes?: number;
}
const {
name,
profession,
text,
rating,
initial,
color,
date,
reviewId,
initialLikes = 0
} = Astro.props;
// Форматируем дату
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
// Цвета для градиентов аватаров
const gradientColors = {
'bg-gradient-1': 'from-purple-500 to-pink-500',
'bg-gradient-2': 'from-blue-500 to-cyan-500',
'bg-gradient-3': 'from-green-500 to-emerald-500',
'bg-gradient-4': 'from-orange-500 to-red-500',
'bg-gradient-5': 'from-indigo-500 to-purple-500',
'bg-gradient-6': 'from-rose-500 to-orange-500',
};
---
<article class="review-card animate-on-scroll" data-animation="fade-up" data-review-id={reviewId} data-initial-likes={initialLikes}>
<!-- Декоративный элемент -->
<div class="card-decoration"></div>
<!-- Кавычки -->
<div class="quote-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<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 class="review-header">
<div class="author-info">
<div class="avatar-wrapper">
<div class={`avatar ${color} ${gradientColors[color] || ''}`}>
<span>{initial}</span>
</div>
<div class="avatar-badge">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="author-details">
<h3 class="author-name">{name}</h3>
<p class="author-profession">{profession}</p>
</div>
</div>
<time class="review-date">{formatDate(date)}</time>
</div>
<!-- Рейтинг отзыва -->
<div class="review-rating">
<div class="rating-stars-display">
{[1, 2, 3, 4, 5].map((star) => (
<svg
class={`star ${star <= rating ? 'filled' : ''}`}
viewBox="0 0 24 24"
fill={star <= rating ? 'currentColor' : 'none'}
stroke="currentColor"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
<span class="rating-value">{rating}.0</span>
</div>
<!-- Текст отзыва -->
<div class="review-text">
<p>"{text}"</p>
</div>
<!-- Нижняя панель с действиями -->
<div class="review-footer">
<button class="like-button" data-vote="likes">
<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">
<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>Полезно</span>
</button>
<div class="share-button">
<span class="likes-count" data-likes-count>{initialLikes}</span>
</div>
<span class="auth-warning" data-auth-warning>Чтобы голосовать, нужно войти</span>
</div>
</article>
<style>
.review-card {
position: relative;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 1.5rem;
padding: 2rem;
box-shadow: 0 20px 35px -10px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(212, 175, 55, 0.2);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 1.5rem;
overflow: hidden;
}
.review-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #d4af37, #fbbf24, #d4af37);
transform: scaleX(0);
transition: transform 0.4s ease;
}
.review-card:hover::before {
transform: scaleX(1);
}
.review-card:hover {
box-shadow: 0 25px 40px -12px rgba(0, 0, 0, 0.15);
border-color: rgba(212, 175, 55, 0.4);
transform: translateY(-6px);
}
/* Декоративный элемент */
.card-decoration {
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(212, 175, 55, 0.05) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
/* Иконка кавычек */
.quote-icon {
position: absolute;
bottom: 1rem;
right: 1rem;
width: 3rem;
height: 3rem;
color: rgba(212, 175, 55, 0.1);
pointer-events: none;
}
.quote-icon svg {
width: 100%;
height: 100%;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
position: relative;
z-index: 1;
}
.author-info {
display: flex;
align-items: center;
gap: 1rem;
}
.avatar-wrapper {
position: relative;
}
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.25rem;
flex-shrink: 0;
position: relative;
transition: all 0.3s ease;
}
.review-card:hover .avatar {
transform: scale(1.05) rotate(2deg);
}
/* Цвета аватаров */
.bg-gradient-1 {
background: linear-gradient(135deg, #8b5cf6, #ec4899);
color: white;
}
.bg-gradient-2 {
background: linear-gradient(135deg, #3b82f6, #06b6d4);
color: white;
}
.bg-gradient-3 {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.bg-gradient-4 {
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: white;
}
.bg-gradient-5 {
background: linear-gradient(135deg, #6366f1, #a855f7);
color: white;
}
.bg-gradient-6 {
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: white;
}
.avatar-badge {
position: absolute;
bottom: -4px;
right: -4px;
width: 1rem;
height: 1rem;
background: #10b981;
border-radius: 50%;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.avatar-badge svg {
width: 0.75rem;
height: 0.75rem;
}
.author-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.author-name {
color: #1e293b;
font-weight: 800;
font-size: 1.125rem;
margin: 0;
background: linear-gradient(135deg, #1e293b, #334155);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.author-profession {
color: #64748b;
font-size: 0.875rem;
margin: 0;
font-weight: 500;
}
.review-date {
color: #94a3b8;
font-size: 0.75rem;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.3px;
}
.review-rating {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-top: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
}
.rating-stars-display {
display: flex;
gap: 0.25rem;
}
.rating-stars-display .star {
width: 1.25rem;
height: 1.25rem;
color: #e2e8f0;
transition: all 0.2s ease;
}
.rating-stars-display .star.filled {
color: #fbbf24;
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.3));
}
.rating-value {
font-weight: 700;
font-size: 0.875rem;
color: #d4af37;
background: rgba(212, 175, 55, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
}
.review-text {
color: #334155;
line-height: 1.7;
font-size: 0.95rem;
position: relative;
z-index: 1;
}
.review-text p {
margin: 0;
font-style: italic;
}
/* Нижняя панель */
.review-footer {
display: flex;
gap: 1rem;
padding-top: 0.5rem;
padding-bottom: 1.5rem;
border-top: 1px solid #e2e8f0;
align-items: center;
position: relative;
}
.auth-warning {
font-size: 0.75rem;
color: #ef4444;
opacity: 0;
transition: opacity 0.3s ease;
position: absolute;
bottom: -3px;
left: 0;
}
.auth-warning.visible {
opacity: 1;
}
.like-button,
.share-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.like-button svg,
.share-button svg {
width: 1rem;
height: 1rem;
}
/* Стили для кнопки лайка с пальцем вверх */
.like-button:hover {
background: #f0fdf4;
border-color: #22c55e;
color: #22c55e;
transform: translateY(-2px);
}
/* Анимация для пальца вверх при нажатии */
.like-button:active svg {
animation: thumbsUp 0.3s ease;
}
@keyframes thumbsUp {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3) rotate(-10deg);
}
100% {
transform: scale(1);
}
}
/* Анимации */
.animate-on-scroll {
opacity: 0;
will-change: opacity, transform;
}
[data-animation="fade-up"] {
transform: translateY(30px);
transition: opacity 0.7s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-on-scroll.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.animate-on-scroll {
opacity: 1;
transform: none;
transition: none;
}
}
@media (max-width: 640px) {
.review-card {
padding: 1.5rem;
text-align: center;
}
.review-header {
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.author-info {
flex-direction: column;
align-items: center;
}
.review-rating {
justify-content: center;
gap: 1rem;
}
.review-footer {
justify-content: center;
}
.quote-icon {
display: none;
}
}
</style>
<script>
import { pb } from '../../lib/pb';
console.log('[ReviewCard] Script loaded');
document.querySelectorAll('.review-card').forEach((card) => {
const reviewId = (card as HTMLElement).dataset.reviewId;
const initialLikes = parseInt((card as HTMLElement).dataset.initialLikes || '0', 10);
const likeBtn = card.querySelector('[data-vote="likes"]') as HTMLButtonElement;
const likesCount = card.querySelector('[data-likes-count]');
console.log('[ReviewCard] reviewId:', reviewId, 'likeBtn:', likeBtn, 'likesCount:', likesCount, 'initialLikes:', initialLikes);
if (!reviewId) {
console.error('[ReviewCard] No reviewId found!');
return;
}
async function loadVotes() {
console.log('[ReviewCard] Loading votes for:', reviewId);
try {
const response = await fetch(`/api/reviews/vote?review_id=${reviewId}`, {
credentials: 'include',
});
console.log('[ReviewCard] loadVotes response:', response.status);
if (response.ok) {
const data = await response.json();
console.log('[ReviewCard] Votes data:', data);
if (likesCount) likesCount.textContent = data.likes.toString();
if (data.userVote === 'likes' && pb.authStore.isValid) {
likeBtn?.classList.add('active');
} else {
likeBtn?.classList.remove('active');
}
} else {
if (likesCount) likesCount.textContent = initialLikes.toString();
}
} catch (e) {
console.error('[ReviewCard] Error loading votes:', e);
if (likesCount) likesCount.textContent = initialLikes.toString();
}
}
loadVotes();
window.addEventListener('storage', (e) => {
if (e.key === 'pb_auth') {
loadVotes();
}
});
likeBtn?.addEventListener('click', async () => {
console.log('[ReviewCard] Like button clicked, auth:', pb.authStore.isValid);
const warningEl = card.querySelector('[data-auth-warning]') as HTMLElement;
if (!pb.authStore.isValid) {
console.log('[ReviewCard] Not authorized, showing warning');
warningEl?.classList.add('visible');
setTimeout(() => {
warningEl?.classList.remove('visible');
}, 3000);
return;
}
try {
console.log('[ReviewCard] Sending vote for:', reviewId);
const response = await fetch('/api/reviews/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_id: reviewId, vote_type: 'likes' }),
credentials: 'include',
});
console.log('[ReviewCard] Vote response:', response.status);
if (response.ok) {
const data = await response.json();
console.log('[ReviewCard] Vote result:', data);
if (likesCount) likesCount.textContent = data.likes.toString();
if (data.userVote === 'likes') {
likeBtn?.classList.add('active');
} else {
likeBtn?.classList.remove('active');
}
} else if (response.status === 401) {
const warningEl = card.querySelector('[data-auth-warning]') as HTMLElement;
warningEl?.classList.add('visible');
setTimeout(() => {
warningEl?.classList.remove('visible');
}, 3000);
} else {
const error = await response.text();
console.error('[ReviewCard] Vote error:', error);
}
} catch (e) {
console.error('[ReviewCard] Error voting:', e);
}
});
});
</script>