diff --git a/backend/pb_migrations/1776691961_updated_reviews.js b/backend/pb_migrations/1776691961_updated_reviews.js new file mode 100644 index 0000000..aa092c9 --- /dev/null +++ b/backend/pb_migrations/1776691961_updated_reviews.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "listRule": "status = \"published\" ", + "viewRule": "status = \"published\" " + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "listRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776691976_updated_reviews.js b/backend/pb_migrations/1776691976_updated_reviews.js new file mode 100644 index 0000000..955d287 --- /dev/null +++ b/backend/pb_migrations/1776691976_updated_reviews.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "createRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776691990_updated_reviews.js b/backend/pb_migrations/1776691990_updated_reviews.js new file mode 100644 index 0000000..56c6e04 --- /dev/null +++ b/backend/pb_migrations/1776691990_updated_reviews.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "updateRule": "user = @request.auth.id || @request.auth.id = \"admin_id\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "updateRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776692012_updated_reviews.js b/backend/pb_migrations/1776692012_updated_reviews.js new file mode 100644 index 0000000..9931b6e --- /dev/null +++ b/backend/pb_migrations/1776692012_updated_reviews.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "deleteRule": "user = @request.auth.id || @request.auth.id = \"admin_id\" " + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "deleteRule": null + }, collection) + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776696068_created_review_votes.js b/backend/pb_migrations/1776696068_created_review_votes.js new file mode 100644 index 0000000..e42ee41 --- /dev/null +++ b/backend/pb_migrations/1776696068_created_review_votes.js @@ -0,0 +1,57 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_362615506", + "indexes": [], + "listRule": null, + "name": "review_votes", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_362615506"); + + return app.delete(collection); +}) diff --git a/backend/pb_migrations/1776696147_updated_review_votes.js b/backend/pb_migrations/1776696147_updated_review_votes.js new file mode 100644 index 0000000..05b2f77 --- /dev/null +++ b/backend/pb_migrations/1776696147_updated_review_votes.js @@ -0,0 +1,65 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_362615506") + + // add field + collection.fields.addAt(1, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_4163081445", + "hidden": false, + "id": "relation2034467270", + "maxSelect": 1, + "minSelect": 0, + "name": "review", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + // add field + collection.fields.addAt(2, new Field({ + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + // add field + collection.fields.addAt(3, new Field({ + "hidden": false, + "id": "select1002219032", + "maxSelect": 1, + "name": "vote_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "likes", + "dislikes" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_362615506") + + // remove field + collection.fields.removeById("relation2034467270") + + // remove field + collection.fields.removeById("relation2375276105") + + // remove field + collection.fields.removeById("select1002219032") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776696207_updated_review_votes.js b/backend/pb_migrations/1776696207_updated_review_votes.js new file mode 100644 index 0000000..9de12df --- /dev/null +++ b/backend/pb_migrations/1776696207_updated_review_votes.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_362615506") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "user = @request.auth.id", + "listRule": "@request.auth.id != \"\"", + "updateRule": "user = @request.auth.id", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_362615506") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/frontend/public/images/sc_pocketbase.jpg b/frontend/public/images/sc_pocketbase.jpg new file mode 100644 index 0000000..22c2453 Binary files /dev/null and b/frontend/public/images/sc_pocketbase.jpg differ diff --git a/frontend/src/components/reviews/ReviewCard.astro b/frontend/src/components/reviews/ReviewCard.astro index d1449d1..f111de6 100644 --- a/frontend/src/components/reviews/ReviewCard.astro +++ b/frontend/src/components/reviews/ReviewCard.astro @@ -1,6 +1,4 @@ --- -import RatingStars from './RatingStars.astro'; - export interface Props { name: string; profession: string; @@ -10,7 +8,7 @@ export interface Props { color: string; date: string; votesCount?: number; - isHelpful?: boolean; + reviewId?: string; } const { @@ -22,7 +20,7 @@ const { color, date, votesCount = 0, - isHelpful = false + reviewId = '' } = Astro.props; // Форматируем дату @@ -53,7 +51,18 @@ const formatDate = (dateStr: string) => {
- +
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
@@ -62,26 +71,41 @@ const formatDate = (dateStr: string) => { -
+

Полезен ли этот отзыв?

-
- +
+ +
- {votesCount} + {votesCount} - {isHelpful && ( - - - - - Полезно - - )}
@@ -160,6 +184,21 @@ const formatDate = (dateStr: string) => { align-items: center; } + .rating-stars-display { + display: flex; + gap: 0.125rem; + } + + .rating-stars-display .star { + width: 1.25rem; + height: 1.25rem; + color: #d1d5db; + } + + .rating-stars-display .star.filled { + color: #fbbf24; + } + .review-text { color: #334155; line-height: 1.7; @@ -186,6 +225,83 @@ const formatDate = (dateStr: string) => { margin: 0; } + .voting-auth-note { + color: #dc2626 !important; + font-size: 0.7rem !important; + font-style: italic; + margin: 0; + } + + .voting-buttons { + display: flex; + gap: 0.75rem; + align-items: center; + } + + .vote-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + background: #ffffff; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + font-weight: 600; + color: #64748b; + } + + .vote-btn svg { + width: 1.25rem; + height: 1.25rem; + } + + .reaction-icon { + width: 1.25rem; + height: 1.25rem; + } + + .vote-btn-up:hover, + .vote-btn-up.active { + border-color: #22c55e; + color: #22c55e; + background: rgba(34, 197, 94, 0.1); + } + + .vote-btn-down:hover, + .vote-btn-down.active { + border-color: #ef4444; + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + } + + .vote-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .vote-label { + display: none; + } + + @media (max-width: 640px) { + .voting-buttons { + flex-direction: column; + width: 100%; + } + + .vote-btn { + width: 100%; + justify-content: center; + } + + .vote-label { + display: inline; + } + } + .voting-stars { display: flex; align-items: center; @@ -195,6 +311,7 @@ const formatDate = (dateStr: string) => { display: flex; align-items: center; gap: 1rem; + margin-top: 0.5rem; } .votes-count { @@ -211,6 +328,36 @@ const formatDate = (dateStr: string) => { height: 1.125rem; } + .auth-prompt { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + background: #1e293b; + color: #fff; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + animation: fadeInUp 0.3s ease; + } + + .auth-prompt a { + color: #d4af37; + text-decoration: underline; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(1rem); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + .helpful-badge { display: inline-flex; align-items: center; @@ -248,6 +395,22 @@ const formatDate = (dateStr: string) => { } } + @media (max-width: 640px) { + .voting-buttons { + flex-direction: column; + width: 100%; + } + + .vote-btn { + width: 100%; + justify-content: center; + } + + .vote-label { + display: inline; + } + } + @media (max-width: 640px) { .review-card { padding: 1.5rem; @@ -279,3 +442,124 @@ const formatDate = (dateStr: string) => { } } + + diff --git a/frontend/src/components/reviews/ReviewsList.astro b/frontend/src/components/reviews/ReviewsList.astro new file mode 100644 index 0000000..969d5dd --- /dev/null +++ b/frontend/src/components/reviews/ReviewsList.astro @@ -0,0 +1,146 @@ +--- +import ReviewCard from './ReviewCard.astro'; +import VotingSummary from './VotingSummary.astro'; + +interface ReviewRecord { + id: string; + name: string; + surname?: string; + profession?: string; + text: string; + rating: number; + status: string; + votesCount: number; + created: string; + expand?: { + user?: { + id: string; + name: string; + firstName?: string; + email: string; + avatar?: string; + }; + }; +} + +const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; + +interface Props { + status?: string; +} + +const { status = 'published' } = Astro.props; + +// Загрузка данных с сервера +let reviews: ReviewRecord[] = []; +let error: string | null = null; +let averageRating = 0; +let totalVotes = 0; +let totalReviews = 0; +let ratingDistribution: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + +try { + const filter = `status = "${status}"`; + const expand = 'user'; + const sort = '-created'; + + const response = await fetch( + `${POCKETBASE_URL}/api/collections/reviews/records?expand=${expand}&filter=${encodeURIComponent(filter)}&sort=${sort}` + ); + + if (response.ok) { + const data = await response.json(); + reviews = data.items || []; + + // Расчёт статистики + totalReviews = reviews.length; + totalVotes = reviews.reduce((sum, r) => sum + (r.votesCount || 0), 0); + const totalRating = reviews.reduce((sum, r) => sum + r.rating, 0); + averageRating = totalReviews > 0 ? parseFloat((totalRating / totalReviews).toFixed(1)) : 0; + + ratingDistribution = reviews.reduce((acc, r) => { + acc[r.rating] = (acc[r.rating] || 0) + 1; + return acc; + }, {} as Record); + } else { + error = 'Не удалось загрузить отзывы'; + } +} catch (e) { + console.error('[ReviewsList] Error:', e); + error = 'Ошибка загрузки отзывов'; +} + +// Цвета для аватарок +const colors = [ + 'bg-blue-100 text-blue-600', 'bg-teal-100 text-teal-600', 'bg-orange-100 text-orange-600', + 'bg-pink-100 text-pink-600', 'bg-purple-100 text-purple-600', 'bg-indigo-100 text-indigo-600', + 'bg-green-100 text-green-600', 'bg-yellow-100 text-yellow-600', 'bg-red-100 text-red-600' +]; + +const getAvatarInfo = (name: string) => { + const initial = name.charAt(0).toUpperCase(); + const color = colors[initial.charCodeAt(0) % colors.length]; + return { initial, color }; +}; +--- + +{error && ( +
+

{error}

+
+)} + +{!error && ( + <> + {reviews.length > 0 && ( + + )} + +
+ {reviews.length > 0 ? ( + reviews.map((review) => { + const avatarInfo = getAvatarInfo(review.name); + const fullName = `${review.name} ${review.surname || ''}`.trim(); + return ( + + ); + }) + ) : ( +
+

Пока нет отзывов. Будьте первым!

+
+ )} +
+ +)} + + \ No newline at end of file diff --git a/frontend/src/components/reviews/VotingSummary.astro b/frontend/src/components/reviews/VotingSummary.astro index 8e4c31f..748a159 100644 --- a/frontend/src/components/reviews/VotingSummary.astro +++ b/frontend/src/components/reviews/VotingSummary.astro @@ -16,8 +16,8 @@ const { averageRating, totalVotes, totalReviews, ratingDistribution } = Astro.pr // Расчёт процентов для каждого рейтинга const getPercentage = (count: number) => { - if (totalVotes === 0) return 0; - return Math.round((count / totalVotes) * 100); + if (totalReviews === 0) return 0; + return Math.round((count / totalReviews) * 100); }; --- @@ -45,7 +45,7 @@ const getPercentage = (count: number) => {

- На основе {totalVotes} голосов + На основе {totalReviews} отзывов

{totalReviews} отзывов оставлено @@ -54,18 +54,21 @@ const getPercentage = (count: number) => {

- {[5, 4, 3, 2, 1].map((rating) => ( -
- {rating} ★ -
-
+ {[5, 4, 3, 2, 1].map((rating) => { + const count = ratingDistribution[rating as keyof typeof ratingDistribution] || 0; + return ( +
+ {rating} ★ +
+
+
+ {count}
- {ratingDistribution[rating as keyof typeof ratingDistribution]} -
- ))} + ); + })}
diff --git a/frontend/src/pages/api/reviews/vote.ts b/frontend/src/pages/api/reviews/vote.ts new file mode 100644 index 0000000..e7f05b3 --- /dev/null +++ b/frontend/src/pages/api/reviews/vote.ts @@ -0,0 +1,252 @@ +import type { APIRoute } from 'astro'; + +const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; + +const POCKETBASE_ID_REGEX = /^[a-z0-9]{15}$/; + +export const POST: APIRoute = async ({ request, cookies }) => { + try { + const token = cookies.get('pb_auth')?.value; + + if (!token) { + return new Response( + JSON.stringify({ error: 'Требуется авторизация' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + const authResponse = await fetch( + `${POCKETBASE_URL}/api/collections/users/auth-refresh`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ); + + if (!authResponse.ok) { + return new Response( + JSON.stringify({ error: 'Недействительная сессия' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + const authData = await authResponse.json(); + const userId = authData.record?.id; + + if (!userId || !POCKETBASE_ID_REGEX.test(userId)) { + return new Response( + JSON.stringify({ error: 'Ошибка идентификации пользователя' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + const body = await request.json(); + const { review_id, vote_type } = body; + + if (!review_id || !POCKETBASE_ID_REGEX.test(review_id)) { + return new Response( + JSON.stringify({ error: 'Некорректный review_id' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + if (!vote_type || !['like', 'dislike'].includes(vote_type)) { + return new Response( + JSON.stringify({ error: 'Некорректный vote_type' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const existingVoteRes = await fetch( + `${POCKETBASE_URL}/api/collections/review_votes/records?` + + new URLSearchParams({ + filter: `review="${review_id}" && user="${userId}"`, + }), + { + headers: { 'Authorization': `Bearer ${token}` }, + } + ); + + let method = 'POST'; + let url = `${POCKETBASE_URL}/api/collections/review_votes/records`; + let voteId = null; + let userVote: 'like' | 'dislike' | null = null; + + if (existingVoteRes.ok) { + const existingData = await existingVoteRes.json(); + if (existingData.items?.length > 0) { + const existing = existingData.items[0]; + voteId = existing.id; + + if (existing.vote_type === vote_type) { + method = 'DELETE'; + url = `${POCKETBASE_URL}/api/collections/review_votes/records/${voteId}`; + } else { + method = 'PATCH'; + url = `${POCKETBASE_URL}/api/collections/review_votes/records/${voteId}`; + userVote = vote_type; + } + } + } + + const voteBody = method === 'POST' + ? JSON.stringify({ review: review_id, user: userId, vote_type }) + : method === 'PATCH' + ? JSON.stringify({ vote_type }) + : null; + + const voteRes = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: voteBody, + }); + + if (!voteRes.ok && method !== 'DELETE') { + const errorText = await voteRes.text(); + console.error('[ReviewVote API] Failed to save vote:', errorText); + throw new Error('Failed to save vote'); + } + + if (method === 'POST') userVote = vote_type; + if (method === 'DELETE') userVote = null; + + const likesRes = await fetch( + `${POCKETBASE_URL}/api/collections/review_votes/records?` + + new URLSearchParams({ + filter: `review="${review_id}"`, + fields: 'vote_type', + }) + ); + + let likes = 0; + let dislikes = 0; + + if (likesRes.ok) { + const votesData = await likesRes.json(); + likes = votesData.items.filter((v: any) => v.vote_type === 'like').length; + dislikes = votesData.items.filter((v: any) => v.vote_type === 'dislike').length; + } + + await fetch( + `${POCKETBASE_URL}/api/collections/reviews/records/${review_id}`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ votesCount: likes }), + } + ); + + return new Response( + JSON.stringify({ likes, dislikes, userVote }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('[ReviewVote API] Error:', error); + return new Response( + JSON.stringify({ error: 'Внутренняя ошибка сервера' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; + +export const GET: APIRoute = async ({ url, cookies }) => { + try { + const reviewId = url.searchParams.get('review_id'); + const token = cookies.get('pb_auth')?.value; + + if (!reviewId || !POCKETBASE_ID_REGEX.test(reviewId)) { + return new Response( + JSON.stringify({ error: 'Некорректный review_id' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const reviewRes = await fetch( + `${POCKETBASE_URL}/api/collections/reviews/records/${reviewId}` + ); + + if (!reviewRes.ok) { + return new Response( + JSON.stringify({ error: 'Отзыв не найден' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + const votesRes = await fetch( + `${POCKETBASE_URL}/api/collections/review_votes/records?` + + new URLSearchParams({ + filter: `review="${reviewId}"`, + fields: 'vote_type', + }) + ); + + let likes = 0; + let dislikes = 0; + + if (votesRes.ok) { + const votesData = await votesRes.json(); + likes = votesData.items.filter((v: any) => v.vote_type === 'like').length; + dislikes = votesData.items.filter((v: any) => v.vote_type === 'dislike').length; + } + + let userVote: 'like' | 'dislike' | null = null; + + if (token) { + try { + const authRes = await fetch( + `${POCKETBASE_URL}/api/collections/users/auth-refresh`, + { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + } + ); + + if (authRes.ok) { + const authData = await authRes.json(); + const userId = authData.record?.id; + + if (userId && POCKETBASE_ID_REGEX.test(userId)) { + const userVoteRes = await fetch( + `${POCKETBASE_URL}/api/collections/review_votes/records?` + + new URLSearchParams({ + filter: `review="${reviewId}" && user="${userId}"`, + }), + { + headers: { 'Authorization': `Bearer ${token}` }, + } + ); + + if (userVoteRes.ok) { + const userVoteData = await userVoteRes.json(); + if (userVoteData.items?.length > 0) { + userVote = userVoteData.items[0].vote_type; + } + } + } + } + } catch (e) { + console.error('[ReviewVote API] Error fetching user vote:', e); + } + } + + return new Response( + JSON.stringify({ likes, dislikes, userVote }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('[ReviewVote API GET] Error:', error); + return new Response( + JSON.stringify({ error: 'Внутренняя ошибка сервера' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; \ No newline at end of file diff --git a/frontend/src/pages/reviews.astro b/frontend/src/pages/reviews.astro index 551635d..3850403 100644 --- a/frontend/src/pages/reviews.astro +++ b/frontend/src/pages/reviews.astro @@ -2,21 +2,8 @@ import Layout from '@layouts/Layout.astro'; import { SITE_URL } from '@constants'; import PageHero from '@components/base/PageHero.astro'; -import Pagination from '@components/base/Pagination.astro'; -import ReviewCard from '@components/reviews/ReviewCard.astro'; import ReviewFormContainer from '@components/reviews/ReviewFormContainer.tsx'; -import ReviewsList from '@components/reviews/ReviewsList.tsx'; -import VotingSummary from '@components/reviews/VotingSummary.astro'; -import AuthLockBlock from '@components/base/AuthLockBlock.astro'; -import { reviewsData, votingSummary } from '@data/reviewsData'; - -const REVIEWS_PER_PAGE = 6; -const currentPage = 1; -const totalPages = Math.ceil(reviewsData.length / REVIEWS_PER_PAGE); - -const startIndex = 0; -const endIndex = REVIEWS_PER_PAGE; -const paginatedReviews = reviewsData.slice(startIndex, endIndex); +import ReviewsList from '@components/reviews/ReviewsList.astro'; --- - +
@@ -208,11 +195,9 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex); // Запуск setupAnimations(); - setupReviewForm(); // Для поддержки View Transitions в Astro document.addEventListener('astro:after-swap', () => { setupAnimations(); - setupReviewForm(); });