diff --git a/.gitignore b/.gitignore
index 3fd5f43..be3ad33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules
.env
.env.local
.next
+package-lock.json
\ No newline at end of file
diff --git a/package.json b/package.json
index b4b4be8..aac20e5 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
+ "react-icons": "^5.5.0",
"react-redux": "^9.2.0",
"resend": "^4.0.1",
"styled-components": "^6.1.13",
diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx
index 5f314db..57f40ee 100644
--- a/src/app/movie/[id]/MovieContent.tsx
+++ b/src/app/movie/[id]/MovieContent.tsx
@@ -7,6 +7,7 @@ import { getImageUrl } from '@/lib/neoApi';
import type { MovieDetails } from '@/lib/api';
import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
+import Reactions from '@/components/Reactions';
import { formatDate } from '@/lib/utils';
import { PlayCircle, ArrowLeft } from 'lucide-react';
@@ -128,6 +129,10 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
/>
+
+
+
+
{/* Desktop-only Embedded Player */}
{imdbId && (
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index 855d81d..009f37a 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -1,9 +1,12 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
+import { authAPI } from '@/lib/authApi';
+import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Loader2, User, LogOut, Trash2 } from 'lucide-react';
+import Modal from '@/components/ui/Modal';
export default function ProfilePage() {
const { logout } = useAuth();
@@ -11,6 +14,7 @@ export default function ProfilePage() {
const [userName, setUserName] = useState
(null);
const [userEmail, setUserEmail] = useState(null);
const [loading, setLoading] = useState(true);
+ const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
const token = localStorage.getItem('token');
@@ -23,9 +27,17 @@ export default function ProfilePage() {
}
}, [router]);
- const handleDeleteAccount = () => {
- // TODO: Implement account deletion logic
- alert('Функция удаления аккаунта в разработке.');
+ const handleConfirmDelete = async () => {
+ try {
+ await authAPI.deleteAccount();
+ toast.success('Аккаунт успешно удален.');
+ setIsModalOpen(false);
+ logout();
+ } catch (error) {
+ console.error('Failed to delete account:', error);
+ toast.error('Не удалось удалить аккаунт. Попробуйте снова.');
+ setIsModalOpen(false);
+ }
};
if (loading) {
@@ -62,7 +74,7 @@ export default function ProfilePage() {
Опасная зона
Это действие нельзя будет отменить. Все ваши данные, включая избранное, будут удалены.
setIsModalOpen(true)}
className="w-full sm:w-auto px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 flex items-center justify-center gap-2"
>
@@ -70,6 +82,14 @@ export default function ProfilePage() {
+ setIsModalOpen(false)}
+ onConfirm={handleConfirmDelete}
+ title="Подтвердите удаление аккаунта"
+ >
+ Вы уверены, что хотите навсегда удалить свой аккаунт? Все ваши данные, включая избранное и реакции, будут безвозвратно удалены. Это действие нельзя будет отменить.
+
);
}
diff --git a/src/app/tv/[id]/TVContent.tsx b/src/app/tv/[id]/TVContent.tsx
index 949e4c6..dbd0af5 100644
--- a/src/app/tv/[id]/TVContent.tsx
+++ b/src/app/tv/[id]/TVContent.tsx
@@ -7,6 +7,7 @@ import { getImageUrl } from '@/lib/neoApi';
import type { TVShowDetails } from '@/lib/api';
import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
+import Reactions from '@/components/Reactions';
import { formatDate } from '@/lib/utils';
import { PlayCircle, ArrowLeft } from 'lucide-react';
@@ -132,6 +133,10 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
/>
+
+
+
+
{imdbId && (
{
+ if (window.dataLayer) {
+ window.dataLayer.push({
+ event: 'pageview',
+ page: pathname,
+ });
+ }
+ }, [pathname]);
return (
diff --git a/src/components/Reactions.tsx b/src/components/Reactions.tsx
new file mode 100644
index 0000000..3d18f87
--- /dev/null
+++ b/src/components/Reactions.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { reactionsAPI, Reaction } from '@/lib/reactionsApi';
+import { toast } from 'react-hot-toast';
+import cn from 'classnames';
+import { formatNumber } from '@/lib/utils';
+import { FaFireAlt } from 'react-icons/fa';
+import { AiFillLike, AiFillDislike } from 'react-icons/ai';
+import { RiEmotionNormalFill } from 'react-icons/ri';
+import { MdMoodBad } from 'react-icons/md';
+
+const reactionTypes: Reaction['type'][] = ['fire', 'nice', 'think', 'bore', 'shit'];
+
+const reactionIcons: Record = {
+ fire: FaFireAlt,
+ nice: AiFillLike,
+ think: RiEmotionNormalFill,
+ bore: MdMoodBad,
+ shit: AiFillDislike,
+};
+
+interface ReactionsProps {
+ mediaId: string;
+ mediaType: 'movie' | 'tv';
+}
+
+const Reactions: React.FC = ({ mediaId, mediaType }) => {
+ const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
+ const [reactionCounts, setReactionCounts] = useState>({});
+ const [userReaction, setUserReaction] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchReactions = async () => {
+ setIsLoading(true);
+ try {
+ const [countsRes, myReactionRes] = await Promise.all([
+ reactionsAPI.getReactionCounts(mediaType, mediaId),
+ token ? reactionsAPI.getMyReaction(mediaType, mediaId) : Promise.resolve(null),
+ ]);
+ setReactionCounts(countsRes.data);
+ if (myReactionRes?.data) {
+ setUserReaction(myReactionRes.data?.type ?? null);
+ }
+ } catch (error) {
+ console.error('Failed to fetch reactions:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchReactions();
+ }, [mediaId, token]);
+
+ const handleReactionClick = async (type: Reaction['type']) => {
+ if (!token) {
+ toast.error('Войдите в аккаунт, чтобы ставить реакции');
+ return;
+ }
+
+ const oldReaction = userReaction;
+ const oldCounts = { ...reactionCounts };
+
+ // Оптимистичное обновление
+ setUserReaction(prev => (prev === type ? null : type));
+ setReactionCounts(prev => {
+ const newCounts = { ...prev };
+ // Если снимаем реакцию
+ if (oldReaction === type) {
+ newCounts[type] = (newCounts[type] || 1) - 1;
+ } else {
+ // Если ставим новую реакцию
+ newCounts[type] = (newCounts[type] || 0) + 1;
+ // Если до этого стояла другая реакция, уменьшаем ее счетчик
+ if (oldReaction) {
+ newCounts[oldReaction] = (newCounts[oldReaction] || 1) - 1;
+ }
+ }
+ return newCounts;
+ });
+
+ try {
+ await reactionsAPI.setReaction(mediaType, mediaId, type);
+ } catch (error) {
+ console.error('Failed to set reaction:', error);
+ toast.error('Не удалось сохранить реакцию');
+ // Откат изменений в случае ошибки
+ setUserReaction(oldReaction);
+ setReactionCounts(oldCounts);
+ }
+ };
+
+ const renderButtons = () => (
+
+ {reactionTypes.map((type) => {
+ const Icon = reactionIcons[type];
+ return (
+
+ handleReactionClick(type)}
+ disabled={isLoading}
+ className={cn(
+ 'w-16 h-12 flex items-center justify-center text-2xl transition-all duration-200 ease-in-out rounded-lg bg-secondary hover:bg-primary/20',
+ {
+ 'bg-primary/30 text-primary scale-110': userReaction === type,
+ 'text-gray-400 hover:text-white': userReaction !== type,
+ }
+ )}
+ title={!token ? `Войдите, чтобы поставить реакцию` : ''}
+ >
+
+
+
+ {formatNumber(reactionCounts[type] || 0)}
+
+
+ );
+ })}
+
+ );
+
+ if (isLoading && Object.keys(reactionCounts).length === 0) {
+ return Загрузка реакций...
;
+ }
+
+ return renderButtons();
+};
+
+export default Reactions;
\ No newline at end of file
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..3595b71
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ children: React.ReactNode;
+}
+
+const Modal: React.FC = ({ isOpen, onClose, onConfirm, title, children }) => {
+ if (!isOpen) return null;
+
+ return (
+
+
+
{title}
+
+ {children}
+
+
+
+ Отмена
+
+
+ Подтвердить удаление
+
+
+
+
+ );
+};
+
+export default Modal;
diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts
index 92175e0..8602c24 100644
--- a/src/lib/authApi.ts
+++ b/src/lib/authApi.ts
@@ -12,5 +12,8 @@ export const authAPI = {
},
login(email: string, password: string) {
return api.post('/auth/login', { email, password });
+ },
+ deleteAccount() {
+ return api.delete('/auth/profile');
}
};
diff --git a/src/lib/reactionsApi.ts b/src/lib/reactionsApi.ts
new file mode 100644
index 0000000..9a1fac0
--- /dev/null
+++ b/src/lib/reactionsApi.ts
@@ -0,0 +1,28 @@
+import { api } from './api';
+
+export interface Reaction {
+ _id: string;
+ userId: string;
+ mediaId: string;
+ mediaType: 'movie' | 'tv';
+ type: 'fire' | 'nice' | 'think' | 'bore' | 'shit';
+ createdAt: string;
+}
+
+export const reactionsAPI = {
+ // [PUBLIC] Получить счетчики для всех типов реакций
+ getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record }> {
+ return api.get(`/reactions/${mediaType}/${mediaId}/counts`);
+ },
+
+ // [AUTH] Получить реакцию пользователя для медиа
+ getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> {
+ return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`);
+ },
+
+ // [AUTH] Установить/обновить/удалить реакцию
+ setReaction(mediaType: string, mediaId: string, type: Reaction['type']): Promise<{ data: Reaction }> {
+ const fullMediaId = `${mediaType}_${mediaId}`;
+ return api.post('/reactions', { mediaId: fullMediaId, type });
+ },
+};
\ No newline at end of file
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index f3c872b..8be6bcc 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -53,3 +53,13 @@ export const formatDate = (dateString: string | Date | undefined | null) => {
return 'Нет даты';
}
};
+
+export function formatNumber(num: number): string {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
+ }
+ return num.toString();
+}