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 && (
+ <>
+ {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 (
+
-
{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';
---
-
+