diff --git a/src/App.js b/src/App.js index 0041774..ba99fc1 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,6 @@ // src/App.jsx import { useEffect, useRef } from 'react'; -import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; import { useAuthContext } from './context/AuthContext'; @@ -11,6 +11,7 @@ import MainFeed from './pages/MainFeed'; import ChatPage from './pages/ChatPage'; import ChatListPage from './pages/ChatListPage'; import ReviewsPage from './pages/ReviewsPage'; +import AdminAdsPage from './pages/AdminAdsPage'; import './styles/chat.css'; function ScrollToTop() { @@ -38,6 +39,15 @@ function UnreadListener() { return null; } +// Защита десктоп-only админ-роутов: доступен только при isAdmin. +// Дополнительная проверка на уровне роута — defense in depth к тому, +// что пункт меню в Sidebar скрыт для не-админов. +function AdminRoute({ children }) { + const { isAdmin } = useAuthContext(); + if (!isAdmin) return ; + return children; +} + function App() { return ( @@ -51,6 +61,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/api/adminAdsApi.js b/src/api/adminAdsApi.js new file mode 100644 index 0000000..ecd2bcb --- /dev/null +++ b/src/api/adminAdsApi.js @@ -0,0 +1,229 @@ +// src/api/adminAdsApi.js +import { API_URL } from "../config/authConstants"; +import { apiFetch } from "./apiClient"; + +const ADMIN_BASE = `${API_URL}/api/v1/admin`; + +/** + * Ошибка API админ-панели. Несёт HTTP-статус, чтобы UI мог отличить + * 400 (невалидные данные) от 422 (недопустимый переход статуса) и т.п. + */ +export class AdminApiError extends Error { + constructor(status, message) { + super(message || `Admin API error ${status}`); + this.name = "AdminApiError"; + this.status = status; + } +} + +async function readErrorMessage(res, fallback) { + try { + const body = await res.json(); + if (body && typeof body.message === "string") return body.message; + if (body && typeof body.error === "string") return body.error; + } catch { + // тело не JSON — используем fallback + } + return fallback; +} + +// ── Кампании ── + +/** + * GET /api/v1/admin/campaigns — список кампаний с вложенными объявлениями. + */ +export async function getCampaigns() { + const res = await apiFetch(`${ADMIN_BASE}/campaigns`); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to load campaigns")); + return res.json(); +} + +export async function createCampaign({ name, startDate, endDate, dailyImpressionCap }) { + const res = await apiFetch(`${ADMIN_BASE}/campaigns`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, startDate, endDate, dailyImpressionCap }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to create campaign")); + return res.json(); +} + +/** + * Ответ 200: CampaignResponseDto. 422 — недопустимый переход статуса. + */ +export async function updateCampaignStatus(campaignId, status) { + const res = await apiFetch(`${ADMIN_BASE}/campaigns/${campaignId}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to update status")); + return res.json(); +} + +/** + * PUT /api/v1/admin/campaigns/{campaignId} — обновление полей кампании + * (без статуса; статус меняется отдельным PATCH-запросом). + * Ответ 200: обновлённый CampaignResponseDto. 400 — невалидные данные. + */ +export async function updateCampaign(campaignId, { name, startDate, endDate, dailyImpressionCap }) { + const res = await apiFetch(`${ADMIN_BASE}/campaigns/${campaignId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, startDate, endDate, dailyImpressionCap }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to update campaign")); + return res.json(); +} + +// ── Объявления (креативы) ── + +/** + * GET /api/v1/admin/ads?campaignId={campaignId} — список объявлений кампании. + * (Lazy-загрузка объявлений при раскрытии карточки кампании.) + */ +export async function getAdvertisingsByCampaign(campaignId) { + const res = await apiFetch(`${ADMIN_BASE}/ads?campaignId=${encodeURIComponent(campaignId)}`); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to load advertisings")); + return res.json(); +} + +/** + * POST /api/v1/admin/ads — создаёт объявление. + * IMAGE → transcodingStatus сразу "READY"; VIDEO → "PENDING". + */ +export async function createAdvertising({ campaignId, type, mediaKey, targetUrl, weight }) { + const res = await apiFetch(`${ADMIN_BASE}/ads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ campaignId, type, mediaKey, targetUrl, weight }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to create advertising")); + return res.json(); +} + +/** + * PUT /api/v1/admin/ads/{advertisingId} — обновляет объявление + * (targetUrl, weight). Тип/медиа/статус транскодинга не меняются. + */ +export async function updateAdvertising(advertisingId, { targetUrl, weight }) { + const res = await apiFetch(`${ADMIN_BASE}/ads/${advertisingId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetUrl, weight }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to update advertising")); + return res.json(); +} + +/** + * POST /api/v1/admin/ads/image — загрузка изображения креатива. + * multipart/form-data (поле "file"), Content-Type НЕ задаём вручную — + * браузер сам проставит boundary. Ответ 200: { mediaKey }. + */ +export async function uploadAdImage(file) { + const formData = new FormData(); + formData.append("file", file); + const res = await apiFetch(`${ADMIN_BASE}/ads/image`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to upload image")); + return res.json(); // { mediaKey } +} + +export async function presignVideoUpload(advertisingId, contentType) { + const res = await apiFetch(`${ADMIN_BASE}/ads/${advertisingId}/video/presign-upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Presign failed")); + return res.json(); // { uploadUrl, sourceKey, expiryMinutes } +} + +/** + * Ответ 202 — транскодинг запущен на бэкенде. + */ +export async function confirmVideoUpload(advertisingId, sourceKey) { + const res = await apiFetch(`${ADMIN_BASE}/ads/${advertisingId}/video/confirm-upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceKey }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Confirm upload failed")); + return res; +} + +export async function getAdvertisingStatus(advertisingId) { + const res = await apiFetch(`${ADMIN_BASE}/ads/${advertisingId}/status`); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Status check failed")); + return res.json(); // { advertisingId, transcodingStatus } +} + +// ── Полноэкранная реклама ── + +/** + * GET /api/v1/admin/fullscreen-ads — список всех роликов (DRAFT/ACTIVE/INACTIVE). + */ +export async function getFullscreenAds() { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads`); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to load fullscreen ads")); + return res.json(); +} + +/** + * POST /api/v1/admin/fullscreen-ads — создаёт ролик (targetUrl необязателен). + */ +export async function createFullscreenAd({ targetUrl }) { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetUrl: targetUrl || null }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Failed to create fullscreen ad")); + return res.json(); +} + +export async function presignFullscreenVideoUpload(fullscreenAdId, contentType) { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads/${fullscreenAdId}/video/presign-upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Presign failed")); + return res.json(); // { uploadUrl, sourceKey, expiryMinutes } +} + +/** + * Ответ 202 — транскодинг запущен на бэкенде. + */ +export async function confirmFullscreenVideoUpload(fullscreenAdId, sourceKey) { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads/${fullscreenAdId}/video/confirm-upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceKey }), + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Confirm upload failed")); + return res; +} + +/** + * PATCH /api/v1/admin/fullscreen-ads/{id}/activate — перевод ролика в ACTIVE + * (предыдущий активный автоматически становится INACTIVE). + */ +export async function activateFullscreenAd(fullscreenAdId) { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads/${fullscreenAdId}/activate`, { + method: "PATCH", + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Activate failed")); + return res.json(); +} + +export async function deactivateFullscreenAd(fullscreenAdId) { + const res = await apiFetch(`${ADMIN_BASE}/fullscreen-ads/${fullscreenAdId}/deactivate`, { + method: "PATCH", + }); + if (!res.ok) throw new AdminApiError(res.status, await readErrorMessage(res, "Deactivate failed")); + return res.json(); +} \ No newline at end of file diff --git a/src/api/adsApi.js b/src/api/adsApi.js new file mode 100644 index 0000000..71e4893 --- /dev/null +++ b/src/api/adsApi.js @@ -0,0 +1,92 @@ +import { API_URL } from "../config/authConstants"; +import { apiFetch } from "./apiClient"; + +/** + * Приводит mediaUrl рекламы к URL, доступному с устройства пользователя. + * + * Presigned S3/MinIO URL (содержат X-Amz-*) НЕ трогаем: подпись включает + * host, любые правки URL инвалидируют её. Если бэк подписывает на localhost, + * а телефон обращается на LAN-IP — это чинится на бэке (подписывать на host + * из запроса / отдавать медиа через обычный прокси-эндпоинт), а не во фронте. + * + * Относительные пути и голые имена файлов резолвим так же, как картинки постов + * (см. getImageUrl). + */ +export function normalizeMediaUrl(mediaUrl) { + if (!mediaUrl) return null; + + // Presigned URL инвалидируется любой модификацией — оставляем как есть. + if (mediaUrl.includes("X-Amz-")) return mediaUrl; + + let url = mediaUrl; + try { + if (!url.startsWith("http")) { + if (url.startsWith("/")) { + url = `${API_URL}${url}`; + } else { + url = `${API_URL}/api/v1/files/${url}`; + } + } + } catch { + url = mediaUrl; + } + return url; +} + +/** + * GET /api/fullscreen-ad — активный полноэкранный ролик. + * 200 → { mediaUrl, targetUrl }; 204 → реклама не настроена. + */ +export async function getFullscreenAd() { + const res = await apiFetch(`${API_URL}/api/fullscreen-ad`); + return res; +} + +export function putWithProgress(url, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url, true); + xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.(Math.round((event.loaded / event.total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed (${xhr.status})`)); + } + }; + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.send(file); + }); +} + +export async function uploadAdVideo(advertisingId, file, onProgress) { + const presignRes = await apiFetch( + `${API_URL}/api/v1/admin/ads/${advertisingId}/video/presign-upload`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: file.type }), + } + ); + if (!presignRes.ok) throw new Error(`Presign failed (${presignRes.status})`); + const { uploadUrl, sourceKey } = await presignRes.json(); + + await putWithProgress(uploadUrl, file, onProgress); + + const confirmRes = await apiFetch( + `${API_URL}/api/v1/admin/ads/${advertisingId}/video/confirm-upload`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceKey }), + } + ); + if (!confirmRes.ok) throw new Error(`Confirm failed (${confirmRes.status})`); + + return { sourceKey }; +} \ No newline at end of file diff --git a/src/components/AdCard.jsx b/src/components/AdCard.jsx new file mode 100644 index 0000000..d08019c --- /dev/null +++ b/src/components/AdCard.jsx @@ -0,0 +1,85 @@ +import { useState, useEffect, useRef } from 'react'; +import { trackAdClick } from '../hooks/useAdClickTracking'; +import { useAdImpressionTracking } from '../hooks/useAdImpressionTracking'; + +const SKIP_DELAY_SECONDS = 5; + +export default function AdCard({ ad, onClose }) { + const [remaining, setRemaining] = useState(SKIP_DELAY_SECONDS); + const cardRef = useRef(null); + const videoRef = useRef(null); + + useAdImpressionTracking(cardRef, ad.advertisingId); + + useEffect(() => { + if (remaining <= 0) return; + const timerId = setTimeout(() => setRemaining((r) => r - 1), 1000); + return () => clearTimeout(timerId); + }, [remaining]); + + // Пауза видео, когда карточка уходит из вьюпорта — не жрёт ресурсы за экраном + useEffect(() => { + const videoNode = videoRef.current; + const cardNode = cardRef.current; + if (!videoNode || !cardNode) return; + + // Свойство muted, а не только HTML-атрибут — критично для iOS: + // без него браузер считает autoplay «со звуком» и блокирует его. + videoNode.muted = true; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + videoNode.muted = true; + videoNode.play().catch(() => {}); // play() может отклоняться браузером, это ок + } else { + videoNode.pause(); + } + }, + { threshold: 0.25 } + ); + + observer.observe(cardNode); + return () => observer.disconnect(); + }, []); + + const handleClick = () => { + trackAdClick(ad.advertisingId); + window.open(ad.targetUrl, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+ Реклама + + {ad.type === 'VIDEO' ? ( +
+ ); +} \ No newline at end of file diff --git a/src/components/AdSlot.jsx b/src/components/AdSlot.jsx new file mode 100644 index 0000000..94e898c --- /dev/null +++ b/src/components/AdSlot.jsx @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; +import { API_URL } from '../config/authConstants'; +import { getAccessToken } from '../api/apiClient'; +import { normalizeMediaUrl } from '../api/adsApi'; +import AdCard from './AdCard'; + +export default function AdSlot() { + const slotRef = useRef(null); + const [ad, setAd] = useState(null); + const [closed, setClosed] = useState(false); + + useEffect(() => { + if (closed) return; + const node = slotRef.current; + if (!node) return; + + let cancelled = false; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + observer.disconnect(); + + const token = getAccessToken(); + fetch(`${API_URL}/api/ads/next`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) + .then((res) => { + // 204 No Content — свободных объявлений нет, оставляем пустой слот + if (res.status === 204) return null; + if (!res.ok) throw new Error(`Error ${res.status}`); + return res.json(); + }) + .then((data) => { + if (!cancelled && data) { + if (data.mediaUrl) data.mediaUrl = normalizeMediaUrl(data.mediaUrl); + setAd(data); + } + }) + .catch(() => {}); + }, + { rootMargin: '200px' } + ); + + observer.observe(node); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [closed]); + + const handleClose = () => setClosed(true); + + if (closed) return null; + + if (ad) { + return ( +
+ +
+ ); + } + + // Зарезервированная высота, чтобы место под объявление не «дёргало» ленту + return
; +} \ No newline at end of file diff --git a/src/components/FullscreenAdOverlay.jsx b/src/components/FullscreenAdOverlay.jsx new file mode 100644 index 0000000..d3fb2c8 --- /dev/null +++ b/src/components/FullscreenAdOverlay.jsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +const SKIP_DELAY_SECONDS = 5; + +/** + * FullscreenAdOverlay — полноэкранный оверлей поверх всего интерфейса + * (z-index выше всех модалок). Кнопка закрытия появляется через 5 секунд + * (тот же паттерн таймера, что и в AdCard). Клик по видео открывает targetUrl + * в новой вкладке (если задан), не закрывая оверлей. + * + * Звук: мобильные браузеры блокируют autoplay со звуком (и останавливают видео + * при попытке программного unmute), поэтому стартуем строго muted — автоплей + * гарантирован на любом устройстве. Звук включается только вручную кнопкой 🔊. + */ +export default function FullscreenAdOverlay({ ad, onClose }) { + const { t } = useTranslation(); + const [remaining, setRemaining] = useState(SKIP_DELAY_SECONDS); + const [soundOn, setSoundOn] = useState(false); + const videoRef = useRef(null); + + useEffect(() => { + if (remaining <= 0) return; + const timerId = setTimeout(() => setRemaining((r) => r - 1), 1000); + return () => clearTimeout(timerId); + }, [remaining]); + + // Автоплей: снимаем muted как свойство ДО старта (атрибут на iOS не всегда + // применяется вовремя), и дополнительно пробуем play() ещё раз, когда + // браузер загрузил данные/может воспроизвести — это покрывает случаи, + // когда первый play() отклонился «NotReady»/«Abort". Никаких автоматических + // размуток — они останавливают видео на мобильных. + useEffect(() => { + const videoNode = videoRef.current; + if (!videoNode) return; + + videoNode.muted = true; + + const tryPlay = () => { + videoNode.muted = true; + const promise = videoNode.play(); + if (promise) promise.catch(() => {}); + }; + + tryPlay(); + videoNode.addEventListener("loadeddata", tryPlay); + videoNode.addEventListener("canplay", tryPlay); + + return () => { + videoNode.removeEventListener("loadeddata", tryPlay); + videoNode.removeEventListener("canplay", tryPlay); + }; + }, []); + + const handleVideoClick = () => { + videoRef.current?.play().catch(() => {}); + if (ad.targetUrl) { + window.open(ad.targetUrl, "_blank", "noopener,noreferrer"); + } + }; + + const handleToggleSound = (e) => { + e.stopPropagation(); + const videoNode = videoRef.current; + if (!videoNode) return; + const nextMuted = !videoNode.muted; + videoNode.muted = nextMuted; + videoNode.play().catch(() => {}); + setSoundOn(!nextMuted); + }; + + return ( +
+ {t('fullscreenAd.label')} +
+ ); +} \ No newline at end of file diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index ce550b5..4fe8eb8 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -9,7 +9,10 @@ import { useTheme } from "../context/ThemeContext"; import { useUnread } from "../context/UnreadContext"; import { useLanguageToggle } from "../hooks/useLanguageToggle"; import { useTranslation } from 'react-i18next'; +import { useAuthContext } from "../context/AuthContext"; +import { useIsDesktop } from "../hooks/useIsDesktop"; import SupportModal from "./SupportModal"; +import UploadAdModal from "./UploadAdModal"; import ProfileAvatar from "./profile/ProfileAvatar"; import NahodkaLogo from "./MainNahodkaLogo"; @@ -28,11 +31,16 @@ export default function Sidebar({ profileData, }) { const navigate = useNavigate(); + const { isAdmin } = useAuthContext(); + // Админ-пункты (реклама) показываем только в десктоп-раскладке: + // на мобильном их не должно быть вообще (ни в drawer-меню). + const isDesktop = useIsDesktop(); const { theme, toggle: toggleTheme } = useTheme(); const { totalUnread } = useUnread(); const { t } = useTranslation(); const { language, toggleLanguage } = useLanguageToggle(); const [supportOpen, setSupportOpen] = useState(false); + const [uploadAdOpen, setUploadAdOpen] = useState(false); const HelpIcon = NavIcons.help; const handleNavItem = (itemKey) => { @@ -100,6 +108,31 @@ export default function Sidebar({ ); })} + {isAdmin && isDesktop && ( + <> +
+ + + + )}
{NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => { @@ -164,6 +197,7 @@ export default function Sidebar({
{supportOpen && setSupportOpen(false)} />} + {uploadAdOpen && setUploadAdOpen(false)} />} ); } diff --git a/src/components/UploadAdModal.jsx b/src/components/UploadAdModal.jsx new file mode 100644 index 0000000..4722c16 --- /dev/null +++ b/src/components/UploadAdModal.jsx @@ -0,0 +1,397 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "./icons/CloseIcon"; +import { + getFullscreenAds, + createFullscreenAd, + presignFullscreenVideoUpload, + confirmFullscreenVideoUpload, + activateFullscreenAd, + deactivateFullscreenAd, +} from "../api/adminAdsApi"; +import { putWithProgress } from "../api/adsApi"; +import { useTranscodingStatusPolling } from "../hooks/useTranscodingStatusPolling"; +import { isProcessingTranscoding } from "../utils/campaignStatus"; + +const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm"; + +/** + * Управление полноэкранной рекламой (список роликов). + * + * - GET /api/v1/admin/fullscreen-ads — список всех роликов сразу; + * - POST /api/v1/admin/fullscreen-ads { targetUrl } — создание ролика + * (targetUrl необязателен); + * - загрузка видео для DRAFT-ролика — тот же трёхшаговый флоу, что у рекламы + * кампаний, но на путях /api/v1/admin/fullscreen-ads/{id}/video/* + * (presign-upload → PUT в MinIO → confirm-upload → поллинг статуса + * транскодинга до READY/FAILED); + * - PATCH /activate и /deactivate для смены активного ролика. + */ +export default function UploadAdModal({ onClose }) { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const pendingUploadIdRef = useRef(null); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + + const [showCreateForm, setShowCreateForm] = useState(false); + const [newTargetUrl, setNewTargetUrl] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + const [uploadingId, setUploadingId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(null); + const [uploadStepError, setUploadStepError] = useState(""); + + const [actionBusyId, setActionBusyId] = useState(null); + const [actionError, setActionError] = useState(""); + const [actionSuccess, setActionSuccess] = useState(""); + + const busy = uploadingId !== null; + + const reload = useCallback(async () => { + setLoadError(""); + try { + const list = await getFullscreenAds(); + setItems(list || []); + } catch { + setLoadError(t("adminPanel.fullscreenLoadError")); + } + }, [t]); + + // Список роликов при монтировании + useEffect(() => { + let cancelled = false; + setLoading(true); + getFullscreenAds() + .then((list) => { + if (cancelled) return; + setItems(list || []); + }) + .catch(() => { + if (cancelled) return; + setLoadError(t("adminPanel.fullscreenLoadError")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [t]); + + // Поллинг транскодинга загружаемого ролика (до READY/FAILED/TIMEOUT) + const pollStatus = useTranscodingStatusPolling(uploadingId, uploadingId !== null, "fullscreen-ads"); + + useEffect(() => { + if (!uploadingId) return; + if (pollStatus === "READY" || pollStatus === "FAILED" || pollStatus === "TIMEOUT") { + setItems((prev) => + prev.map((i) => (i.id === uploadingId ? { ...i, transcodingStatus: pollStatus } : i)) + ); + setUploadingId(null); + setUploadProgress(null); + } + }, [pollStatus, uploadingId]); + + const closeModal = () => { + if (busy) return; + onClose(); + }; + + // ── Создание ролика ── + const handleCreate = async () => { + if (creating) return; + let targetUrl = newTargetUrl.trim(); + if (targetUrl) { + if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`; + try { + // eslint-disable-next-line no-new + new URL(targetUrl); + } catch { + setCreateError(t("adminPanel.urlInvalid")); + return; + } + } + setCreating(true); + setCreateError(""); + setActionError(""); + setActionSuccess(""); + try { + const created = await createFullscreenAd({ targetUrl: targetUrl || null }); + setItems((prev) => [created, ...prev]); + setNewTargetUrl(""); + setShowCreateForm(false); + setActionSuccess(t("adminPanel.fullscreenCreated")); + } catch (err) { + setCreateError( + err?.status === 400 ? t("adminPanel.adCreateFailed") : t("adminPanel.adServerError") + ); + } finally { + setCreating(false); + } + }; + + // ── Загрузка видео (трёхшаговый флоу + поллинг статуса) ── + const handlePickVideo = (item) => { + if (busy) return; + pendingUploadIdRef.current = item.id; + if (fileInputRef.current) fileInputRef.current.value = ""; + fileInputRef.current?.click(); + }; + + const handleFileSelected = async (e) => { + const file = e.target.files?.[0] || null; + const itemId = pendingUploadIdRef.current; + pendingUploadIdRef.current = null; + if (!file || itemId == null) return; + + if (!file.type.startsWith("video/")) { + setUploadStepError(t("admin.onlyVideo")); + return; + } + + setUploadStepError(""); + setActionError(""); + setActionSuccess(""); + setUploadProgress(0); + setUploadingId(itemId); + + try { + // Шаг 2: presign + const { uploadUrl, sourceKey } = await presignFullscreenVideoUpload(itemId, file.type); + // Шаг 3: прямой PUT в MinIO без Authorization приложения + await putWithProgress(uploadUrl, file, (p) => setUploadProgress(p)); + // Шаг 4: confirm — 202, запуск транскодинга на бэкенде + await confirmFullscreenVideoUpload(itemId, sourceKey); + // Дальше статус дозревает через поллинг (useTranscodingStatusPolling) + setItems((prev) => + prev.map((i) => (i.id === itemId ? { ...i, transcodingStatus: "PROCESSING" } : i)) + ); + } catch (err) { + setUploadingId(null); + setUploadProgress(null); + setUploadStepError( + err?.name === "AdminApiError" ? t("adminPanel.adServerError") : t("adminPanel.uploadFailed") + ); + } + }; + + // ── Активация / деактивация ── + const handleActivate = async (item) => { + if (actionBusyId) return; + setActionBusyId(item.id); + setActionError(""); + setActionSuccess(""); + try { + await activateFullscreenAd(item.id); + await reload(); + setActionSuccess(t("adminPanel.fullscreenActivated")); + } catch (err) { + setActionError( + err?.status === 409 || err?.status === 422 + ? t("adminPanel.fullscreenActivateInvalid") + : t("adminPanel.fullscreenActionFailed") + ); + } finally { + setActionBusyId(null); + } + }; + + const handleDeactivate = async (item) => { + if (actionBusyId) return; + setActionBusyId(item.id); + setActionError(""); + setActionSuccess(""); + try { + await deactivateFullscreenAd(item.id); + await reload(); + setActionSuccess(t("adminPanel.fullscreenDeactivated")); + } catch (err) { + setActionError(t("adminPanel.fullscreenActionFailed")); + } finally { + setActionBusyId(null); + } + }; + + const activeCount = items.filter((i) => i.status === "ACTIVE").length; + + return ( +
e.target === e.currentTarget && closeModal()}> +
+
+ {t('admin.fullscreenTitle')} + +
+ +
+ +
+ + {showCreateForm && ( +
+ + { + setNewTargetUrl(e.target.value); + setCreateError(""); + }} + disabled={creating} + /> +
{t('adminPanel.fullscreenUrlHint')}
+ {createError &&
{createError}
} + +
+ )} + +
+ {t('adminPanel.fullscreenHint')} +
+ {activeCount > 0 && ( +
{t('adminPanel.fullscreenActivateHint')}
+ )} + + {actionSuccess &&
{actionSuccess}
} + {actionError &&
{actionError}
} + {uploadStepError &&
{uploadStepError}
} + {loadError &&
{loadError}
} + + + + {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
{t('adminPanel.fullscreenEmpty')}
+ ) : ( +
+ {items.map((item) => { + const isUploading = uploadingId === item.id; + const ready = item.transcodingStatus === "READY"; + const processing = isProcessingTranscoding(item.transcodingStatus); + const isActive = item.status === "ACTIVE"; + return ( +
+ + {statusLabel(item.status, t)} + + {item.transcodingStatus && ( + + {transcodingLabel(item.transcodingStatus, t)} + + )} +
+ {item.targetUrl ? ( +
{item.targetUrl}
+ ) : ( +
{t('adminPanel.fullscreenNoUrl')}
+ )} + {!isActive && ready && ( +
{t('adminPanel.fullscreenCanActivate')}
+ )} +
+
+ {isUploading ? ( + + {t('adminPanel.saving')} + + ) : item.status === "DRAFT" && !ready ? ( + + ) : ready && !isActive ? ( + + ) : isActive ? ( + + ) : null} +
+ {isUploading && uploadProgress !== null && ( +
+
+
+
+
{uploadProgress}%
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} + +function statusLabel(status, t) { + if (status === "ACTIVE") return t("adminPanel.fullscreenStatusActive"); + if (status === "INACTIVE") return t("adminPanel.fullscreenStatusInactive"); + return t("adminPanel.fullscreenStatusDraft"); +} + +function transcodingLabel(status, t) { + if (isProcessingTranscoding(status)) return t("adminPanel.processing"); + if (status === "READY") return t("adminPanel.ready"); + if (status === "TIMEOUT") return t("adminPanel.processingTimeout"); + if (status === "FAILED") return t("adminPanel.failed"); + return status || "—"; +} \ No newline at end of file diff --git a/src/components/admin/AdvertisingCreateModal.jsx b/src/components/admin/AdvertisingCreateModal.jsx new file mode 100644 index 0000000..804f559 --- /dev/null +++ b/src/components/admin/AdvertisingCreateModal.jsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "../icons/CloseIcon"; +import { + createAdvertising, + presignVideoUpload, + confirmVideoUpload, + uploadAdImage, +} from "../../api/adminAdsApi"; +import { putWithProgress } from "../../api/adsApi"; +import { MAX_SIZE_MB } from "../../hooks/useImageUpload"; + +const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm"; +const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp"; + +/** + * Создание объявления (креатива) в кампании. + * + * IMAGE: файл уходит на POST /api/v1/admin/ads/image сразу при выборе + * (валидация размера/типа — клиентская, ошибки до отправки). + * Полученный mediaKey хранится в состоянии и подставляется в + * POST /api/v1/admin/ads при сабмите. Пока загрузка не завершена + * (или упала), кнопка "Добавить объявление" заблокирована. + * VIDEO: трёхшаговый флоу create → presign → PUT на MinIO → confirm, + * после чего модалка закрывается, а статус транскодинга поллится + * на странице списка кампаний (см. AdminAdsPage). + */ +export default function AdvertisingCreateModal({ campaignId, onClose, onCreated }) { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + const [file, setFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [progress, setProgress] = useState(null); // 0..100 во время PUT в MinIO + const [stepError, setStepError] = useState(""); // ошибка конкретного шага + const [submitError, setSubmitError] = useState(""); // общая ошибка + + // IMAGE-загрузка: файл, превью (object URL) и mediaKey, полученный с бэкенда. + const [mediaKey, setMediaKey] = useState(""); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [imageError, setImageError] = useState(""); + const [uploadingImage, setUploadingImage] = useState(false); + + // Освобождаем object URL превью при замене файла и размонтировании. + useEffect(() => { + return () => { + if (imagePreview) URL.revokeObjectURL(imagePreview); + }; + }, [imagePreview]); + + const schema = useMemo( + () => z.object({ + type: z.enum(["IMAGE", "VIDEO"]), + targetUrl: z.string().trim().url(t("adminPanel.urlInvalid")), + weight: z.coerce.number().int(t("adminPanel.weightInvalid")).positive(t("adminPanel.weightInvalid")), + }), + [t] + ); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + mode: "onSubmit", + defaultValues: { type: "IMAGE", targetUrl: "", weight: "" }, + }); + + const type = watch("type"); + + const handleFileChange = (e) => { + const selected = e.target.files?.[0] || null; + setFile(selected); + setStepError(""); + setSubmitError(""); + }; + + /** + * IMAGE: валидацию (тип, размер ≤ 10 МБ) делаем на клиенте и показываем + * ошибку сразу; затем сразу POST /api/v1/admin/ads/image и сохраняем mediaKey. + * Превью — локальный object URL выбранного файла (до ответа сервера). + */ + const handleImageChange = async (e) => { + const selected = e.target.files?.[0] || null; + // Сбрасываем input заранее, чтобы повторно можно было выбрать тот же файл + if (imageInputRef.current) imageInputRef.current.value = ""; + + setImageError(""); + setStepError(""); + setSubmitError(""); + + if (!selected) return; + + if (!selected.type.startsWith("image/")) { + setImageError(t('imageUpload.invalidType')); + return; + } + if (selected.size > MAX_SIZE_MB * 1024 * 1024) { + setImageError(t('imageUpload.tooLarge', { maxSize: MAX_SIZE_MB })); + return; + } + + setImageFile(selected); + setImagePreview(URL.createObjectURL(selected)); + setMediaKey(""); + setUploadingImage(true); + + try { + const { mediaKey: key } = await uploadAdImage(selected); + setMediaKey(key); + } catch { + setImageError(t('adminPanel.uploadFailed')); + } finally { + setUploadingImage(false); + } + }; + + const handleRemoveImage = () => { + setImageFile(null); + setImagePreview(null); + setMediaKey(""); + setImageError(""); + }; + + /** Шаги 2–4 видео-флоу: пресайн → PUT на MinIO → confirm. */ + const uploadVideoSteps = async (advertisingId, selectedFile, onNext) => { + // Шаг 2: presign + onNext("presign"); + const { uploadUrl, sourceKey } = await presignVideoUpload(advertisingId, selectedFile.type); + + // Шаг 3: прямой PUT сторонним доменом MinIO без Authorization приложения + onNext("upload"); + await putWithProgress(uploadUrl, selectedFile, (p) => setProgress(p)); + + // Шаг 4: confirm — 202, запуск транскодинга + onNext("confirm"); + await confirmVideoUpload(advertisingId, sourceKey); + }; + + const onSubmit = async (values) => { + if (submitting) return; + setSubmitting(true); + setSubmitError(""); + setStepError(""); + + const { type: adType, targetUrl, weight } = values; + + // Для IMAGE обязателен уже полученный mediaKey (кнопка заблокирована, + // пока загрузка не завершилась/не упала) — защита от сабмита через Enter. + if (adType === "IMAGE" && !mediaKey) { + setStepError(t("adminPanel.mediaKeyRequired")); + setSubmitting(false); + return; + } + + // Для VIDEO файл обязателен: проверяем ДО создания объявления на бэкенде, + // чтобы не оставить осиротевшее объявление (созданное, но без медиа) + // в статусе PENDING. Форма при этом не сбрасывается. + if (adType === "VIDEO" && !file) { + setStepError(t("adminPanel.videoRequired")); + setSubmitting(false); + return; + } + + try { + // Шаг 1 (общий): создание объявления + const ad = await createAdvertising({ + campaignId, + type: adType, + mediaKey: adType === "IMAGE" ? mediaKey : null, + targetUrl, + weight: Number(weight), + }); + + if (adType === "IMAGE") { + // IMAGE → transcodingStatus сразу "READY", загрузки нет. + onCreated({ ...ad, transcodingStatus: ad.transcodingStatus || "READY" }); + return; + } + + await uploadVideoSteps( + ad.id, + file, + (stage) => { + if (stage === "upload") setProgress(0); + setSubmitError(""); + setStepError(""); + } + ); + + // Модалку закрываем сразу после confirm; транскодинг поллится страницей. + onCreated({ ...ad, transcodingStatus: "PENDING" }); + } catch (err) { + setSubmitting(false); + setProgress(null); + if (err?.status === 400) { + setSubmitError(t("adminPanel.adCreateFailed")); + return; + } + // Контекст не теряем: по шагу показываем понятную ошибку + setStepError( + err?.name === "AdminApiError" + ? t("adminPanel.adServerError") + : t("adminPanel.uploadFailed") + ); + } + }; + + return ( +
e.target === e.currentTarget && !submitting && onClose()}> +
+
+ {t('adminPanel.adCreateTitle')} + +
+ +
+
+ + + {errors.type &&
{errors.type.message}
} +
+ +
+ + + {errors.targetUrl &&
{errors.targetUrl.message}
} +
+ +
+ + + {errors.weight ? ( +
{errors.weight.message}
+ ) : ( +
{t('adminPanel.weightHint')}
+ )} +
+ + {type === "IMAGE" ? ( +
+ + + {imagePreview ? ( + <> +
+ {imageFile?.name + +
+ {imageFile && ( +
+ {uploadingImage ? t('adminPanel.adImageUploading') : imageFile.name} +
+ )} + + ) : ( + + )} + + {imageError ? ( +
{imageError}
+ ) : ( +
{t('adminPanel.adImageHint', { maxSize: MAX_SIZE_MB })}
+ )} +
+ ) : ( +
+ + +
{t('adminPanel.adVideoHint')}
+
+ )} + + {progress !== null && submitting && ( +
+
+
+
+
{progress}%
+
+ )} + + {stepError &&
{stepError}
} + {submitError &&
{submitError}
} + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/AdvertisingEditModal.jsx b/src/components/admin/AdvertisingEditModal.jsx new file mode 100644 index 0000000..e9a6808 --- /dev/null +++ b/src/components/admin/AdvertisingEditModal.jsx @@ -0,0 +1,113 @@ +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "../icons/CloseIcon"; +import { updateAdvertising } from "../../api/adminAdsApi"; + +/** + * Редактирование объявления: только targetUrl и weight. + * Тип, медиа и статус транскодинга фиксируются при создании — + * смена креатива делается через новое объявление. + */ +export default function AdvertisingEditModal({ ad, onClose, onUpdated }) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + + const schema = useMemo( + () => z.object({ + targetUrl: z.string().trim().url(t("adminPanel.urlInvalid")), + weight: z.coerce.number().int(t("adminPanel.weightInvalid")).positive(t("adminPanel.weightInvalid")), + }), + [t] + ); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + mode: "onSubmit", + defaultValues: { + targetUrl: ad.targetUrl || "", + weight: ad.weight ?? "", + }, + }); + + const onSubmit = async (values) => { + if (submitting) return; + setSubmitting(true); + setSubmitError(""); + try { + const updated = await updateAdvertising(ad.id, { + targetUrl: values.targetUrl, + weight: Number(values.weight), + }); + onUpdated({ ...ad, ...updated }); + } catch (err) { + setSubmitError( + err?.status === 400 ? t("adminPanel.adEditInvalid") : t("adminPanel.adEditFailed") + ); + setSubmitting(false); + } + }; + + return ( +
e.target === e.currentTarget && !submitting && onClose()}> +
+
+ {t('adminPanel.adEditTitle')} + +
+ +
+
+ + + {errors.targetUrl &&
{errors.targetUrl.message}
} +
+ +
+ + + {errors.weight ? ( +
{errors.weight.message}
+ ) : ( +
{t('adminPanel.weightHint')}
+ )} +
+ + {submitError &&
{submitError}
} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/CampaignCreateModal.jsx b/src/components/admin/CampaignCreateModal.jsx new file mode 100644 index 0000000..85cc91d --- /dev/null +++ b/src/components/admin/CampaignCreateModal.jsx @@ -0,0 +1,69 @@ +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "../icons/CloseIcon"; +import { createCampaign } from "../../api/adminAdsApi"; +import CampaignFormFields, { createCampaignSchema, toLocalDateTime } from "./CampaignFormFields"; + +export default function CampaignCreateModal({ onClose, onCreated }) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + + const schema = useMemo(() => createCampaignSchema(t), [t]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(schema), mode: "onSubmit" }); + + const onSubmit = async (values) => { + if (submitting) return; + setSubmitting(true); + setSubmitError(""); + try { + const campaign = await createCampaign({ + name: values.name, + startDate: toLocalDateTime(values.startDate), + endDate: toLocalDateTime(values.endDate), + dailyImpressionCap: Number(values.dailyImpressionCap), + }); + onCreated(campaign); + } catch (err) { + // 400 — по контракту «Проверьте даты и лимит показов» + setSubmitError( + err?.status === 400 ? t("adminPanel.campDateCapError") : t("adminPanel.campCreateFailed") + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
e.target === e.currentTarget && !submitting && onClose()}> +
+
+ {t('adminPanel.campaignCreateTitle')} + +
+ +
+ + + {submitError &&
{submitError}
} + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/CampaignEditModal.jsx b/src/components/admin/CampaignEditModal.jsx new file mode 100644 index 0000000..af3203e --- /dev/null +++ b/src/components/admin/CampaignEditModal.jsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "../icons/CloseIcon"; +import { updateCampaign } from "../../api/adminAdsApi"; +import CampaignFormFields, { createCampaignSchema, toDatetimeLocalValue, toLocalDateTime } from "./CampaignFormFields"; + +/** + * Редактирование полей кампании (название, даты, лимит показов). + * Статус намеренно не включён — он меняется отдельной модалкой + * (CampaignStatusUpdateModal) и отдельным PATCH-запросом. + */ +export default function CampaignEditModal({ campaign, onClose, onUpdated }) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + + const schema = useMemo(() => createCampaignSchema(t), [t]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + mode: "onSubmit", + defaultValues: { + name: campaign.name || "", + startDate: toDatetimeLocalValue(campaign.startDate), + endDate: toDatetimeLocalValue(campaign.endDate), + dailyImpressionCap: campaign.dailyImpressionCap ?? "", + }, + }); + + const onSubmit = async (values) => { + if (submitting) return; + setSubmitting(true); + setSubmitError(""); + try { + const updated = await updateCampaign(campaign.id, { + name: values.name, + startDate: toLocalDateTime(values.startDate), + endDate: toLocalDateTime(values.endDate), + dailyImpressionCap: Number(values.dailyImpressionCap), + }); + onUpdated({ ...campaign, ...updated }); + } catch (err) { + // 400 — по контракту «Проверьте даты и лимит показов» + setSubmitError( + err?.status === 400 ? t("adminPanel.campDateCapError") : t("adminPanel.campUpdateFailed") + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
e.target === e.currentTarget && !submitting && onClose()}> +
+
+ {t('adminPanel.campaignEditTitle')} + +
+ +
+ + + {submitError &&
{submitError}
} + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/CampaignFormFields.jsx b/src/components/admin/CampaignFormFields.jsx new file mode 100644 index 0000000..ef9e0e8 --- /dev/null +++ b/src/components/admin/CampaignFormFields.jsx @@ -0,0 +1,112 @@ +import { z } from "zod"; + +/** + * Общие поля формы кампании (name / startDate / endDate / dailyImpressionCap), + * переиспользуются в CampaignCreateModal и CampaignEditModal. + * + * Статус намеренно НЕ входит — он редактируется отдельным действием. + */ + +/** "2026-09-20T14:30" → "2026-09-20T14:30:00" (LocalDateTime без таймзоны). */ +export function toLocalDateTime(value) { + const trimmed = (value || "").trim(); + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(trimmed)) return `${trimmed}:00`; + return trimmed; +} + +/** "2026-09-20T14:30:00" → "2026-09-20T14:30" (для value input.datetime-local). */ +export function toDatetimeLocalValue(value) { + const trimmed = (value || "").trim(); + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/.exec(trimmed); + return match ? match[1] : trimmed; +} + +export function createCampaignSchema(t) { + return z + .object({ + name: z.string().trim().min(1, t("adminPanel.fieldRequired")).max(255, t("adminPanel.nameTooLong")), + startDate: z.string().min(1, t("adminPanel.fieldRequired")), + endDate: z.string().min(1, t("adminPanel.fieldRequired")), + dailyImpressionCap: z.coerce + .number() + .int(t("adminPanel.capInvalid")) + .positive(t("adminPanel.capInvalid")), + }) + .superRefine((data, ctx) => { + if (!data.startDate || !data.endDate) return; + if ( + new Date(toLocalDateTime(data.endDate)).getTime() <= + new Date(toLocalDateTime(data.startDate)).getTime() + ) { + ctx.addIssue({ + code: "custom", + path: ["endDate"], + message: t("adminPanel.endAfterStart"), + }); + } + }); +} + +export default function CampaignFormFields({ register, errors, submitting, t }) { + return ( + <> +
+ + + {errors.name &&
{errors.name.message}
} +
+ +
+
+ + + {errors.startDate &&
{errors.startDate.message}
} +
+
+ + + {errors.endDate &&
{errors.endDate.message}
} +
+
+ +
+ + + {errors.dailyImpressionCap ? ( +
{errors.dailyImpressionCap.message}
+ ) : ( +
{t('adminPanel.campaignCapHint')}
+ )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/admin/CampaignStatusUpdateModal.jsx b/src/components/admin/CampaignStatusUpdateModal.jsx new file mode 100644 index 0000000..d9323d2 --- /dev/null +++ b/src/components/admin/CampaignStatusUpdateModal.jsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "../icons/CloseIcon"; +import { updateCampaignStatus } from "../../api/adminAdsApi"; +import { getNextCampaignStatuses } from "../../utils/campaignStatus"; + +export default function CampaignStatusUpdateModal({ campaignId, currentStatus, onClose, onUpdated }) { + const { t } = useTranslation(); + const allowed = getNextCampaignStatuses(currentStatus); + const [nextStatus, setNextStatus] = useState(allowed[0] || ""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + + const hasTransitions = allowed.length > 0; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!hasTransitions || submitting || !nextStatus) return; + + setSubmitting(true); + setSubmitError(""); + try { + const updated = await updateCampaignStatus(campaignId, nextStatus); + onUpdated(campaignId, updated.status || nextStatus); + } catch (err) { + // 422 — по контракту «Такой переход статуса недопустим» + setSubmitError( + err?.status === 422 ? t("adminPanel.statusTransitionError") : t("adminPanel.statusChangeFailed") + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
e.target === e.currentTarget && !submitting && onClose()} + > +
+
+ {t('adminPanel.adStatusTitle')} + +
+ +
+
+ {t('adminPanel.currentStatus')} +

+ + {currentStatus} + +

+
+ + {hasTransitions ? ( + <> +
+ + +
+ {submitError &&
{submitError}
} + + + ) : ( +

{t('adminPanel.noTransitions')}

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/feed/FeedContent.jsx b/src/components/feed/FeedContent.jsx index 22e6227..25d5b17 100644 --- a/src/components/feed/FeedContent.jsx +++ b/src/components/feed/FeedContent.jsx @@ -1,4 +1,9 @@ +import { useMemo } from "react"; import PostOnFeed from "../../pages/PostOnFeed"; +import AdSlot from "../AdSlot"; +import { withAds } from "../../utils/withAds"; + +const AD_EVERY = 10; export default function FeedContent({ loading, @@ -17,8 +22,16 @@ export default function FeedContent({ onImageClick, onLocationClick, onAuthRequired, + showAds = false, t, }) { + // Реклама вставляется только на главной ленте (showAds). useMemo — чтобы + // при ре-рендерах (виждет-обновления, лайки и т.п.) позиции слотов не + // пересчитывались и уже загруженные объявления не терялись. + const feedItems = useMemo( + () => withAds(filteredAds, showAds ? AD_EVERY : 0), + [filteredAds, showAds] + ); if (loading || (citySearchActive && cityLoading)) { return (
@@ -68,20 +81,28 @@ export default function FeedContent({ ); } - return filteredAds.map(ad => ( - - )); + return ( + <> + {feedItems.map((item) => + item.kind === "ad" ? ( + + ) : ( + + ) + )} + + ); } \ No newline at end of file diff --git a/src/components/icons/NavIcons.jsx b/src/components/icons/NavIcons.jsx index 142097b..fa69d66 100644 --- a/src/components/icons/NavIcons.jsx +++ b/src/components/icons/NavIcons.jsx @@ -56,6 +56,12 @@ export const NavIcons = { ), + megaphone: () => ( + + + + + ), plus: () => ( diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index f36552f..4e1249d 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -65,6 +65,10 @@ export function AuthProvider({ children }) { user, token, isAuthenticated: !!token, + // Вычисляемое поле вместо повторяющегося user?.role === "ADMIN" + // в каждом компоненте. Валидация роли — на бэкенде; здесь только + // UI-флаг для показа админ-элементов. + isAdmin: !!user && user.role === "ADMIN", login, logout, updateUser, diff --git a/src/hooks/useAdClickTracking.js b/src/hooks/useAdClickTracking.js new file mode 100644 index 0000000..1f6bf00 --- /dev/null +++ b/src/hooks/useAdClickTracking.js @@ -0,0 +1,17 @@ +import { API_URL } from '../config/authConstants'; +import { getAdSessionId } from '../utils/adSession'; +import { apiFetch } from '../api/apiClient'; + +export function trackAdClick(advertisingId) { + const payload = JSON.stringify({ + advertisingId, + sessionId: getAdSessionId(), + }); + + apiFetch(`${API_URL}/api/ads/click`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(() => {}); +} \ No newline at end of file diff --git a/src/hooks/useAdImpressionTracking.js b/src/hooks/useAdImpressionTracking.js new file mode 100644 index 0000000..01e8900 --- /dev/null +++ b/src/hooks/useAdImpressionTracking.js @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { API_URL } from '../config/authConstants'; +import { getAdSessionId } from '../utils/adSession'; +import { apiFetch } from '../api/apiClient'; + +export function useAdImpressionTracking(ref, advertisingId) { + useEffect(() => { + const node = ref.current; + if (!node || !advertisingId) return; + + let sent = false; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || sent) return; + sent = true; + observer.disconnect(); + + const payload = JSON.stringify({ + advertisingId, + sessionId: getAdSessionId(), + }); + + apiFetch(`${API_URL}/api/ads/impression`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, // отправка при уходе со страницы, как sendBeacon + }).catch(() => {}); + }, + { threshold: 0.25 } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [ref, advertisingId]); +} \ No newline at end of file diff --git a/src/hooks/useFullscreenAdTrigger.js b/src/hooks/useFullscreenAdTrigger.js new file mode 100644 index 0000000..9f694c5 --- /dev/null +++ b/src/hooks/useFullscreenAdTrigger.js @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getFullscreenAd, normalizeMediaUrl } from "../api/adsApi"; + +const SESSION_KEY = "fullscreenAdShown"; +const SCROLL_PAUSE_MS = 2000; + +/** + * useFullscreenAdTrigger — ловит момент, когда пользователь суммарно + * активно проскроллил ленту `triggerAfterSeconds` секунд, и один раз за сессию + * запрашивает полноэкранную рекламу. + * + * «Активный скролл»: время накапливается только между последовательными + * scroll-событиями, если между ними прошло не больше 2 секунд; иначе это пауза, + * и время через неё не накапливается. + * + * Результат: + * - GET /api/fullscreen-ad → 200: оверлей с рекламой, sessionStorage + * "fullscreenAdShown" ставится, отслеживание останавливается; + * - → 204: реклама не настроена — помечаем сессию и больше не запрашиваем; + * - иные ошибки: не помечаем сессию, накопление продолжается (повтор на том же + * триггере позже). + * + * Скролл не «баблится», но на стадии capture ловится у любого вложенного + * контейнера (лента скроллится внутри .f-feed-scroll). + * + * @param {number} [triggerAfterSeconds=60] + * @param {boolean} [enabled=true] — например, «реклама включена в ленте» + * @returns {{ ad: object|null, close: () => void }} + */ +export function useFullscreenAdTrigger(triggerAfterSeconds = 20, enabled = true) { + const [ad, setAd] = useState(null); + const triggerMsRef = useRef(Math.max(1, triggerAfterSeconds) * 1000); + + useEffect(() => { + triggerMsRef.current = Math.max(1, triggerAfterSeconds) * 1000; + }, [triggerAfterSeconds]); + + const close = useCallback(() => setAd(null), []); + + useEffect(() => { + if (!enabled) { + console.info(`[fullscreen-ad] disabled (showAds=${enabled}), not tracking scroll`); + return; + } + if (sessionStorage.getItem(SESSION_KEY)) { + console.info(`[fullscreen-ad] skipped: session already marked (${SESSION_KEY})`); + return; + } + + let accumulatedMs = 0; + let lastScrollAt = null; + let fired = false; + + const fire = async () => { + if (fired) return; + fired = true; + accumulatedMs = 0; + console.info(`[fullscreen-ad] scroll threshold reached (${triggerMsRef.current}ms), firing`); + + try { + console.info("[fullscreen-ad] GET /api/fullscreen-ad"); + const res = await getFullscreenAd(); + if (res.status === 204) { + // Реклама не настроена — в этой сессии больше не пробуем + console.info("[fullscreen-ad] 204 No Content -> marking session, no retry"); + sessionStorage.setItem(SESSION_KEY, "1"); + } else if (res.ok) { + const data = await res.json(); + if (data.mediaUrl) data.mediaUrl = normalizeMediaUrl(data.mediaUrl); + console.info("[fullscreen-ad] 200 OK, mediaUrl=", data.mediaUrl, "targetUrl=", data.targetUrl); + sessionStorage.setItem(SESSION_KEY, "1"); + setAd(data); + } else { + // Ошибка сервера — не помечаем сессию, попробуем при следующем триггере + console.warn(`[fullscreen-ad] non-200 response: ${res.status}`); + fired = false; + } + } catch (error) { + console.warn("[fullscreen-ad] request failed:", error); + fired = false; + } + }; + + const onScroll = () => { + if (fired) return; + const now = performance.now(); + if (lastScrollAt !== null) { + const delta = now - lastScrollAt; + if (delta <= SCROLL_PAUSE_MS) accumulatedMs += delta; + } + lastScrollAt = now; + + if (accumulatedMs >= triggerMsRef.current) { + fire(); + } + }; + + window.addEventListener("scroll", onScroll, { capture: true, passive: true }); + return () => window.removeEventListener("scroll", onScroll, { capture: true }); + }, [enabled]); + + return { ad, close }; +} \ No newline at end of file diff --git a/src/hooks/useIsDesktop.js b/src/hooks/useIsDesktop.js new file mode 100644 index 0000000..6cfb769 --- /dev/null +++ b/src/hooks/useIsDesktop.js @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +// Тот же брейкпоинт, что использует sidebar.css: на >=900px левая навигация +// становится статичной (десктоп-раскладка), ниже — мобильная. +const DESKTOP_QUERY = "(min-width: 900px)"; + +function matchesDesktop() { + return typeof window !== "undefined" && window.matchMedia(DESKTOP_QUERY).matches; +} + +/** + * Хук «шире 900px». Используется для десктоп-only страниц (админ-панель): + * весь связанный UI не показывается на мобильной раскладке вообще. + */ +export function useIsDesktop() { + const [isDesktop, setIsDesktop] = useState(matchesDesktop); + + useEffect(() => { + const mql = window.matchMedia(DESKTOP_QUERY); + const onChange = (e) => setIsDesktop(e.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isDesktop; +} \ No newline at end of file diff --git a/src/hooks/useTranscodingStatusPolling.js b/src/hooks/useTranscodingStatusPolling.js new file mode 100644 index 0000000..062d420 --- /dev/null +++ b/src/hooks/useTranscodingStatusPolling.js @@ -0,0 +1,66 @@ +import { useState, useEffect, useRef } from 'react'; +import { apiFetch } from '../api/apiClient'; +import { API_URL } from '../config/authConstants'; + +const TERMINAL_STATUSES = ['READY', 'FAILED']; +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 100; + +/** + * Поллинг статуса транскодинга медиа. + * @param {number|string} advertisingId — id сущности + * @param {boolean} enabled — включать поллинг + * @param {string} [path="ads"] — сегмент пути (например, "ads" или "fullscreen-ads") + * @returns {string} транскодинг-статус и его производные (PROCESSING/READY/FAILED/TIMEOUT) + */ +export function useTranscodingStatusPolling(advertisingId, enabled, path = "ads") { + const [status, setStatus] = useState('PROCESSING'); + const attemptsRef = useRef(0); + + useEffect(() => { + if (!enabled || !advertisingId) return; + + attemptsRef.current = 0; + let cancelled = false; + let timeoutId; + + const poll = async () => { + if (cancelled) return; + + try { + const res = await apiFetch(`${API_URL}/api/v1/admin/${path}/${advertisingId}/status`); + + if (!res.ok) { + throw new Error(`Status check failed: ${res.status}`); + } + + const data = await res.json(); + if (cancelled) return; + + setStatus(data.transcodingStatus); + + attemptsRef.current += 1; + const isTerminal = TERMINAL_STATUSES.includes(data.transcodingStatus); + const exceededAttempts = attemptsRef.current >= MAX_POLL_ATTEMPTS; + + if (!isTerminal && !exceededAttempts) { + timeoutId = setTimeout(poll, POLL_INTERVAL_MS); + } else if (exceededAttempts && !isTerminal) { + setStatus('TIMEOUT'); + } + } catch (err) { + console.warn('Status poll failed, retrying', err); + timeoutId = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + poll(); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [advertisingId, enabled, path]); + + return status; +} \ No newline at end of file diff --git a/src/hooks/useTranscodingStatusSocket.js b/src/hooks/useTranscodingStatusSocket.js new file mode 100644 index 0000000..13b1413 --- /dev/null +++ b/src/hooks/useTranscodingStatusSocket.js @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { useStompClient } from './useStompClient'; // ваш существующий хук из чата + +/** + * STOMP/WebSocket-подписка на статус транскодинга медиа. + * @param {number|string} advertisingId — id сущности + * @param {boolean} enabled — включать подписку + * @param {string} [path="ads"] — сегмент пути (например, "ads" или "fullscreen-ads") + * @returns {string} транскодинг-статус + */ +export function useTranscodingStatusSocket(advertisingId, enabled, path = "ads") { + const [status, setStatus] = useState('PROCESSING'); + const stompClient = useStompClient(); + + useEffect(() => { + if (!enabled || !advertisingId || !stompClient?.connected) return; + + const subscription = stompClient.subscribe( + `/topic/${path}/${advertisingId}/status`, + (message) => { + const payload = JSON.parse(message.body); + setStatus(payload.transcodingStatus); + } + ); + + return () => subscription.unsubscribe(); + }, [advertisingId, enabled, path, stompClient]); + + return status; +} \ No newline at end of file diff --git a/src/locales/en.json b/src/locales/en.json index 3d8808f..5cd4192 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -226,6 +226,121 @@ "footPrivacy": "Privacy", "footSupport": "Support" }, + "admin": { + "uploadAd": "Fullscreen ad", + "fullscreenTitle": "Fullscreen ad", + "title": "Upload ad video", + "advertisingId": "Advertising ID", + "advertisingIdPlaceholder": "e.g. 1", + "selectVideo": "📹 Choose video", + "selectVideoHint": "MP4 / WebM · video file", + "upload": "Upload", + "uploading": "Uploading...", + "success": "Video uploaded, transcoding started", + "uploadError": "Failed to upload the video", + "onlyVideo": "Only video files can be uploaded" + }, + "adminPanel": { + "campaignsNav": "Ads (campaigns)", + "pageTitle": "Advertising campaigns", + "persistHint": "Campaigns and creatives are stored on the server", + "campaignsLoading": "Loading campaigns...", + "campaignsLoadError": "Failed to load campaigns", + "createCampaign": "Create campaign", + "campaignCreated": "Campaign created", + "addAd": "Add creative", + "adCreated": "Creative added", + "videoUploaded": "Video uploaded, transcoding started", + "statusUpdated": "Campaign status updated", + "noCampaigns": "No campaigns yet", + "noCampaignsSub": "Create your first campaign — once moved to ACTIVE, its creatives start appearing in the feed.", + "dates": "Dates", + "dailyCap": "Daily impression cap", + "changeStatus": "Change status", + "ads": "Creatives", + "noAds": "No creatives in this campaign yet", + "showAds": "Show creatives", + "hideAds": "Hide creatives", + "adsLoading": "Loading creatives...", + "adsLoadError": "Failed to load creatives", + "targetUrl": "Target URL", + "weight": "Weight", + "weightHint": "Integer greater than 0", + "processing": "Processing video...", + "processingTimeout": "Processing is taking unusually long, check later", + "ready": "Ready", + "failed": "Processing failed", + "campaignCreateTitle": "New campaign", + "campaignCreateSubmit": "Create campaign", + "campaignEditTitle": "Edit campaign", + "editCampaign": "Edit", + "saveCampaign": "Save", + "campaignUpdated": "Campaign updated", + "campUpdateFailed": "Failed to update campaign", + "campaignName": "Name", + "campaignStart": "Start", + "campaignEnd": "End", + "campaignCap": "Daily impression cap", + "campaignCapHint": "Integer greater than 0", + "campDateCapError": "Check dates and impression cap", + "campCreateFailed": "Failed to create campaign", + "adCreateTitle": "New creative", + "adCreateSubmit": "Add creative", + "adEditTitle": "Edit creative", + "adEditSubmit": "Save", + "adEditInvalid": "Check the URL and weight", + "adEditFailed": "Failed to update creative", + "adUpdated": "Creative updated", + "editAdvertising": "Edit", + "saving": "Saving...", + "savingAd": "Uploading...", + "adType": "Type", + "adImage": "Upload image", + "adImageHint": "JPEG / PNG / WebP · up to {{maxSize}} MB", + "adImageUploading": "Uploading image...", + "adImageRemove": "Remove image", + "adVideo": "Choose video", + "adVideoHint": "MP4 / MOV / WebM · video file", + "videoRequired": "Select a video file", + "adCreateFailed": "Check the creative data", + "adServerError": "Failed to create creative, try again", + "uploadFailed": "Failed to upload file to storage, try again", + "adStatusTitle": "Change campaign status", + "currentStatus": "Current status", + "newStatus": "New status", + "statusTransitionError": "Such a status transition is not allowed", + "statusChangeFailed": "Failed to change campaign status", + "noTransitions": "Campaign is finished — status cannot be changed.", + "apply": "Apply", + "nameTooLong": "Maximum 255 characters", + "endAfterStart": "End date must be strictly later than start date", + "capInvalid": "Enter an integer greater than 0", + "urlInvalid": "Enter a valid URL", + "weightInvalid": "Enter an integer greater than 0", + "mediaKeyRequired": "Upload an image", + "fieldRequired": "Required", + "fullscreenCreate": "Create video", + "fullscreenCreateSubmit": "Create video", + "fullscreenUrlOptional": "optional", + "fullscreenUrlHint": "Opens in a new tab when the video is clicked. Can be left empty.", + "fullscreenLoadError": "Failed to load fullscreen videos", + "fullscreenEmpty": "No fullscreen videos yet", + "fullscreenHint": "Shown once per session after 60 seconds of active feed scrolling in total.", + "fullscreenActivateHint": "Activating a new video automatically makes the previous active one inactive.", + "fullscreenCreated": "Video created", + "fullscreenUploadVideo": "Upload video", + "fullscreenMakeActive": "Make active", + "fullscreenDeactivate": "Deactivate", + "fullscreenActivated": "Video activated", + "fullscreenDeactivated": "Video deactivated", + "fullscreenActionFailed": "Failed to change video status", + "fullscreenActivateInvalid": "A video with this status cannot be activated", + "fullscreenStatusDraft": "Draft", + "fullscreenStatusActive": "Active", + "fullscreenStatusInactive": "Inactive", + "fullscreenNoUrl": "No target URL", + "fullscreenCanActivate": "Ready to activate" + }, "landing": { "eyebrow": "Service for finding lost pets and items", "heroTitle": "We believe that everything lost can be found.", @@ -379,6 +494,12 @@ "common": { "close": "Close" }, + "fullscreenAd": { + "label": "Ad", + "closeIn": "Close in {{seconds}}s", + "mute": "Mute sound", + "unmute": "Unmute sound" + }, "viewer": { "goToImage": "Go to image {{number}}" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 0ed6dd4..61e9b6b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -227,6 +227,121 @@ "footPrivacy": "Конфиденциальность", "footSupport": "Поддержка" }, + "admin": { + "uploadAd": "Полноэкранная реклама", + "fullscreenTitle": "Полноэкранная реклама", + "title": "Загрузка видео рекламы", + "advertisingId": "ID рекламы", + "advertisingIdPlaceholder": "Например: 1", + "selectVideo": "📹 Выбрать видео", + "selectVideoHint": "MP4 / WebM · видеофайл", + "upload": "Загрузить", + "uploading": "Загрузка...", + "success": "Видео загружено, запущен транскодинг", + "uploadError": "Не удалось загрузить видео", + "onlyVideo": "Можно загружать только видеофайлы" + }, + "adminPanel": { + "campaignsNav": "Реклама (кампании)", + "pageTitle": "Рекламные кампании", + "persistHint": "Кампании и объявления хранятся на сервере", + "campaignsLoading": "Загружаем кампании…", + "campaignsLoadError": "Не удалось загрузить кампании", + "createCampaign": "Создать кампанию", + "campaignCreated": "Кампания создана", + "addAd": "Добавить объявление", + "adCreated": "Объявление добавлено", + "videoUploaded": "Видео загружено, запущен транскодинг", + "statusUpdated": "Статус кампании обновлён", + "noCampaigns": "Кампаний пока нет", + "noCampaignsSub": "Создайте первую кампанию — после перевода в статус ACTIVE её объявления начнут появляться в ленте.", + "dates": "Даты", + "dailyCap": "Лимит показов/день", + "changeStatus": "Сменить статус", + "ads": "Объявления", + "noAds": "Объявлений в кампании ещё нет", + "showAds": "Показать объявления", + "hideAds": "Скрыть объявления", + "adsLoading": "Загружаем объявления…", + "adsLoadError": "Не удалось загрузить объявления", + "targetUrl": "Ссылка перехода", + "weight": "Вес", + "weightHint": "Целое число больше 0", + "processing": "Обработка видео...", + "processingTimeout": "Обработка занимает необычно долго, проверьте позже", + "ready": "Готово", + "failed": "Ошибка обработки", + "campaignCreateTitle": "Новая кампания", + "campaignCreateSubmit": "Создать кампанию", + "campaignEditTitle": "Редактирование кампании", + "editCampaign": "Редактировать", + "saveCampaign": "Сохранить", + "campaignUpdated": "Кампания обновлена", + "campUpdateFailed": "Не удалось обновить кампанию", + "campaignName": "Название", + "campaignStart": "Начало", + "campaignEnd": "Окончание", + "campaignCap": "Лимит показов в день", + "campaignCapHint": "Целое число больше 0", + "campDateCapError": "Проверьте даты и лимит показов", + "campCreateFailed": "Не удалось создать кампанию", + "adCreateTitle": "Новое объявление", + "adCreateSubmit": "Добавить объявление", + "adEditTitle": "Редактирование объявления", + "adEditSubmit": "Сохранить", + "adEditInvalid": "Проверьте ссылку и вес", + "adEditFailed": "Не удалось обновить объявление", + "adUpdated": "Объявление обновлено", + "editAdvertising": "Редактировать", + "saving": "Сохранение...", + "savingAd": "Загрузка...", + "adType": "Тип", + "adImage": "Загрузить изображение", + "adImageHint": "JPEG / PNG / WebP · до {{maxSize}} МБ", + "adImageUploading": "Загрузка изображения...", + "adImageRemove": "Удалить изображение", + "adVideo": "Выбрать видео", + "adVideoHint": "MP4 / MOV / WebM · видеофайл", + "videoRequired": "Выберите видеофайл", + "adCreateFailed": "Проверьте данные объявления", + "adServerError": "Не удалось создать объявление, попробуйте снова", + "uploadFailed": "Не удалось загрузить файл в хранилище, попробуйте снова", + "adStatusTitle": "Смена статуса кампании", + "currentStatus": "Текущий статус", + "newStatus": "Новый статус", + "statusTransitionError": "Такой переход статуса недопустим", + "statusChangeFailed": "Не удалось изменить статус кампании", + "noTransitions": "Кампания завершена — статус изменить нельзя.", + "apply": "Применить", + "nameTooLong": "Максимум 255 символов", + "endAfterStart": "Дата окончания должна быть строго позже даты начала", + "capInvalid": "Укажите целое число больше 0", + "urlInvalid": "Введите корректный URL", + "weightInvalid": "Укажите целое число больше 0", + "mediaKeyRequired": "Загрузите изображение", + "fieldRequired": "Обязательное поле", + "fullscreenCreate": "Создать ролик", + "fullscreenCreateSubmit": "Создать ролик", + "fullscreenUrlOptional": "необязательно", + "fullscreenUrlHint": "Ссылка откроется в новой вкладке при клике по ролику. Можно оставить пустой.", + "fullscreenLoadError": "Не удалось загрузить полноэкранные ролики", + "fullscreenEmpty": "Полноэкранных роликов пока нет", + "fullscreenHint": "Показывается один раз за сессию после суммарно 60 секунд активного скролла ленты.", + "fullscreenActivateHint": "При активации нового ролика предыдущий активный автоматически станет неактивным.", + "fullscreenCreated": "Ролик создан", + "fullscreenUploadVideo": "Загрузить видео", + "fullscreenMakeActive": "Сделать активным", + "fullscreenDeactivate": "Деактивировать", + "fullscreenActivated": "Ролик активирован", + "fullscreenDeactivated": "Ролик деактивирован", + "fullscreenActionFailed": "Не удалось изменить статус ролика", + "fullscreenActivateInvalid": "Ролик с таким статусом нельзя активировать", + "fullscreenStatusDraft": "Черновик", + "fullscreenStatusActive": "Активен", + "fullscreenStatusInactive": "Неактивен", + "fullscreenNoUrl": "Переход не задан", + "fullscreenCanActivate": "Доступен для активации" + }, "landing": { "eyebrow": "Сервис поиска пропавших питомцев и вещей", "heroTitle": "Верим, что всё потерянное можно найти.", @@ -380,6 +495,12 @@ "common": { "close": "Закрыть" }, + "fullscreenAd": { + "label": "Реклама", + "closeIn": "Закрыть через {{seconds}}с", + "mute": "Выключить звук", + "unmute": "Включить звук" + }, "viewer": { "goToImage": "Перейти к изображению {{number}}" }, diff --git a/src/pages/AdminAdsPage.jsx b/src/pages/AdminAdsPage.jsx new file mode 100644 index 0000000..a3baba4 --- /dev/null +++ b/src/pages/AdminAdsPage.jsx @@ -0,0 +1,492 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, Navigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuthContext } from "../context/AuthContext"; +import { useIsDesktop } from "../hooks/useIsDesktop"; +import { useModals } from "../hooks/useModals"; +import { useProfile } from "../hooks/useProfile"; +import { getImageUrl } from "../config/authConstants"; +import Sidebar from "../components/Sidebar"; +import AppTopBar from "../components/layout/AppTopBar"; +import Toast from "../components/Toast"; +import { ProfileModal } from "../components/profile"; +import AuthModal from "../components/AuthModal"; +import RulesModal from "../pages/RulesModal"; +import CampaignCreateModal from "../components/admin/CampaignCreateModal"; +import CampaignEditModal from "../components/admin/CampaignEditModal"; +import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal"; +import AdvertisingEditModal from "../components/admin/AdvertisingEditModal"; +import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal"; +import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi"; +import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus"; +import "../styles/admin.css"; + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 100; // ≈ 5 минут + +/** "2026-09-20T14:30:00" → "20.09.2026 14:30" */ +function formatLocalDateTime(value) { + if (!value) return "—"; + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); + if (!match) return value; + return `${match[3]}.${match[2]}.${match[1]} ${match[4]}:${match[5]}`; +} + +/** + * Десктоп-only админ-панель управления рекламными кампаниями. + * + * Список кампаний подгружается с бэкенда при монтировании + * (GET /api/v1/admin/campaigns), а объявления конкретной кампании — + * лениво при раскрытии её карточки (GET /api/v1/admin/ads?campaignId={id}). + * Создание/изменение отправляется через POST/PATCH и дополняет локальное + * состояние без полной перезагрузки. + */ +export default function AdminAdsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext(); + const { fetchProfile } = useProfile(); + const isDesktop = useIsDesktop(); + const modals = useModals(); + + const [campaigns, setCampaigns] = useState([]); // { id, name, status, startDate, endDate, dailyImpressionCap, ads: [], adsLoaded, adsLoading, adsLoadError, adsAttempted } + const [campaignsLoading, setCampaignsLoading] = useState(true); + const [campaignsLoadError, setCampaignsLoadError] = useState(""); + const [expandedCampaignIds, setExpandedCampaignIds] = useState(() => new Set()); + const [toast, setToast] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const profileFetchedFor = useRef(null); + + const profileData = useMemo(() => { + if (!user) return {}; + const nameParts = (user.name || "").trim().split(/\s+/); + return { + login: user.login || "", + firstName: user.firstName || nameParts[0] || "", + lastName: user.lastName || nameParts.slice(1).join(" ") || "", + email: user.email || "", + phone: user.phone || "", + avatar: getImageUrl(user.avatar), + id: user.id, + }; + }, [user]); + + // Подтягиваем свежие данные профиля (как в MainFeed/ReviewsPage) + useEffect(() => { + if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) { + profileFetchedFor.current = user.id; + fetchProfile(user.id).then((result) => { + if (result.success) updateUser(result.data); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, user?.id]); + + // Подгружаем реальный список кампаний при монтировании + useEffect(() => { + if (!isAdmin) return; + let cancelled = false; + setCampaignsLoading(true); + setCampaignsLoadError(""); + getCampaigns() + .then((list) => { + if (cancelled) return; + setCampaigns((list || []).map((c) => ({ + ...c, + ads: c.ads || [], + adsLoaded: false, + adsLoading: false, + adsLoadError: "", + adsAttempted: false, + }))); + }) + .catch(() => { + if (cancelled) return; + setCampaignsLoadError(t("adminPanel.campaignsLoadError")); + }) + .finally(() => { + if (!cancelled) setCampaignsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isAdmin, t]); + + const showToast = useCallback((message, type = "success") => { + setToast({ message, type }); + }, []); + + const handleCampaignCreated = useCallback((campaign) => { + setCampaigns((prev) => [ + ...prev, + { ...campaign, ads: [], adsLoaded: true, adsLoading: false, adsLoadError: "", adsAttempted: false }, + ]); + modals.close(); + showToast(t("adminPanel.campaignCreated")); + }, [modals, showToast, t]); + + const handleAdCreated = useCallback((ad) => { + setCampaigns((prev) => + prev.map((c) => (c.id === ad.campaignId ? { ...c, ads: [...(c.ads || []), ad] } : c)) + ); + modals.close(); + showToast( + ad.transcodingStatus === "PENDING" ? t("adminPanel.videoUploaded") : t("adminPanel.adCreated") + ); + }, [modals, showToast, t]); + + const handleAdUpdated = useCallback((updatedAd) => { + setCampaigns((prev) => + prev.map((c) => + c.id === updatedAd.campaignId + ? { ...c, ads: c.ads.map((a) => (a.id === updatedAd.id ? updatedAd : a)) } + : c + ) + ); + modals.close(); + showToast(t("adminPanel.adUpdated")); + }, [modals, showToast, t]); + + // Раскрытие/сворачивание карточки кампании + ленивая загрузка объявлений + const handleToggleCampaign = useCallback((campaignId) => { + const willExpand = !expandedCampaignIds.has(campaignId); + + setExpandedCampaignIds((prev) => { + const next = new Set(prev); + if (next.has(campaignId)) next.delete(campaignId); + else next.add(campaignId); + return next; + }); + + if (!willExpand) { + // Сворачиваем: сбрасываем флаг попытки только при ошибке, чтобы повторное + // раскрытие дало возможность догрузить объявления. + setCampaigns((prev) => + prev.map((c) => + c.id === campaignId + ? { ...c, adsLoading: false, adsAttempted: c.adsLoadError ? false : c.adsAttempted } + : c + ) + ); + return; + } + + const campaign = campaigns.find((c) => c.id === campaignId); + if (!campaign || campaign.adsLoaded || campaign.adsLoading || campaign.adsAttempted) return; + + setCampaigns((prev) => + prev.map((c) => (c.id === campaignId ? { ...c, adsLoading: true, adsAttempted: true } : c)) + ); + getAdvertisingsByCampaign(campaignId) + .then((list) => { + setCampaigns((prev) => + prev.map((c) => + c.id === campaignId + ? { ...c, ads: list || [], adsLoaded: true, adsLoading: false, adsLoadError: "" } + : c + ) + ); + }) + .catch(() => { + setCampaigns((prev) => + prev.map((c) => + c.id === campaignId ? { ...c, adsLoading: false, adsLoadError: t("adminPanel.adsLoadError") } : c + ) + ); + }); + }, [campaigns, expandedCampaignIds, t]); + + const handleStatusUpdated = useCallback((campaignId, status) => { + setCampaigns((prev) => + prev.map((c) => (c.id === campaignId ? { ...c, status } : c)) + ); + modals.close(); + showToast(t("adminPanel.statusUpdated")); + }, [modals, showToast, t]); + + const handleCampaignUpdated = useCallback((campaign) => { + setCampaigns((prev) => + prev.map((c) => (c.id === campaign.id ? { ...c, ...campaign, ads: c.ads } : c)) + ); + modals.close(); + showToast(t("adminPanel.campaignUpdated")); + }, [modals, showToast, t]); + + // ── Поллинг транскодинга видео-объявлений ── + // Каждые 3 сек опрашиваем статус всех «обрабатывающихся» видео, пока они + // не уйдут в READY/FAILED (максимум ~100 попыток → TIMEOUT). + const pendingAds = useMemo( + () => + campaigns.flatMap((c) => + c.ads.filter((a) => isProcessingTranscoding(a.transcodingStatus)).map((a) => ({ ...a, campaignId: c.id })) + ), + [campaigns] + ); + const pendingKey = pendingAds.map((a) => a.id).join(","); + const pendingAdsRef = useRef(pendingAds); + useEffect(() => { + pendingAdsRef.current = pendingAds; + }, [pendingAds]); + + useEffect(() => { + if (!pendingKey) return; + + const attempts = {}; + pendingAds.forEach((a) => { attempts[a.id] = 0; }); + + const poll = async () => { + for (const ad of pendingAdsRef.current) { + attempts[ad.id] = (attempts[ad.id] || 0) + 1; + try { + const { transcodingStatus } = await getAdvertisingStatus(ad.id); + if (transcodingStatus === "READY" || transcodingStatus === "FAILED") { + setCampaigns((prev) => + prev.map((c) => + c.id === ad.campaignId + ? { ...c, ads: c.ads.map((x) => (x.id === ad.id ? { ...x, transcodingStatus } : x)) } + : c + ) + ); + } else if (attempts[ad.id] >= MAX_POLL_ATTEMPTS) { + setCampaigns((prev) => + prev.map((c) => + c.id === ad.campaignId + ? { ...c, ads: c.ads.map((x) => (x.id === ad.id ? { ...x, transcodingStatus: "TIMEOUT" } : x)) } + : c + ) + ); + } + } catch { + // Временный сбой сети — просто пропускаем тик, попытка не теряется + } + } + }; + + poll(); + const timer = setInterval(poll, POLL_INTERVAL_MS); + return () => clearInterval(timer); + // pendingAds читается через pendingAdsRef (см. выше) — зависимость не нужна + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingKey]); + + // Мобильная раскладка — страница не показывается вообще (desktop-only): + // /admin/ads недоступен с мобильного, перенаправляем на ленту. + // (Проверка после всех хуков, чтобы не нарушать порядок вызова хуков.) + if (!isDesktop) return ; + + return ( +
+ setSidebarOpen(false)} + onNavClick={() => navigate("/feed")} + onLogoClick={() => navigate("/feed")} + isAuthenticated={isAuthenticated} + onLoginClick={() => modals.open("auth")} + onRulesClick={() => modals.open("rules")} + onCreatePost={() => navigate("/feed")} + onProfileClick={() => modals.open("profile")} + profileData={profileData} + /> + +
+ setSidebarOpen((v) => !v)} + isAuthenticated={isAuthenticated} + profileData={profileData} + onProfileClick={() => modals.open("profile")} + onLoginClick={() => modals.open("auth")} + hideWordmark + centerContent={

{t('adminPanel.pageTitle')}

} + /> + +
+
+
+
+
+ {campaignsLoading + ? t('adminPanel.campaignsLoading') + : t('adminPanel.persistHint')} +
+ {campaignsLoadError &&
{campaignsLoadError}
} +
+ +
+ + {!campaignsLoading && campaigns.length === 0 && ( +
+
📣
+
{t('adminPanel.noCampaigns')}
+
{t('adminPanel.noCampaignsSub')}
+
+ )} + + {campaigns.map((campaign) => { + const allowedNext = getNextCampaignStatuses(campaign.status); + return ( +
+
+
+ {campaign.name} + + {campaign.status} + +
+
+ + + {allowedNext.length > 0 && ( + + )} + +
+
+ +
+ {t('adminPanel.dates')}: {formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)} + {t('adminPanel.dailyCap')}: {campaign.dailyImpressionCap} +
+ + {expandedCampaignIds.has(campaign.id) && ( + <> +
{t('adminPanel.ads')}
+ {campaign.adsLoading ? ( +
{t('adminPanel.adsLoading')}
+ ) : campaign.adsLoadError ? ( +
{campaign.adsLoadError}
+ ) : campaign.ads.length === 0 ? ( +
{t('adminPanel.noAds')}
+ ) : ( +
+ {campaign.ads.map((ad) => ( +
+ {ad.type} +
+
{ad.targetUrl}
+
+ {t('adminPanel.weight')}: {ad.weight} +
+
+ + {transcodingBadgeText(ad, t)} + + +
+ ))} +
+ )} + + )} +
+ ); + })} +
+
+
+ + {modals.isOpen("campaign-create") && isAdmin && ( + + )} + {modals.isOpen("campaign-edit") && isAdmin && ( + + )} + {modals.isOpen("advertising-create") && isAdmin && ( + + )} + {modals.isOpen("advertising-edit") && isAdmin && ( + + )} + {modals.isOpen("campaign-status-update") && isAdmin && ( + + )} + {modals.isOpen("profile") && isAuthenticated && ( + + )} + {modals.isOpen("auth") && } + {modals.isOpen("rules") && } + + {toast && ( + setToast(null)} /> + )} +
+ ); +} + +/** Бейдж статуса транскодинга объявления. */ +function transcodingBadgeText(ad, t) { + if (isProcessingTranscoding(ad.transcodingStatus)) return t("adminPanel.processing"); + if (ad.transcodingStatus === "READY") return t("adminPanel.ready"); + if (ad.transcodingStatus === "TIMEOUT") return t("adminPanel.processingTimeout"); + if (ad.transcodingStatus === "FAILED") return t("adminPanel.failed"); + return ad.transcodingStatus || "—"; +} + +function transcodingBadgeClass(ad) { + if (isProcessingTranscoding(ad.transcodingStatus)) return "processing"; + if (ad.transcodingStatus === "READY") return "ready"; + if (ad.transcodingStatus === "TIMEOUT") return "timeout"; + if (ad.transcodingStatus === "FAILED") return "failed"; + return "draft"; +} \ No newline at end of file diff --git a/src/pages/MainFeed.tsx b/src/pages/MainFeed.tsx index ea15ff4..6b77e62 100644 --- a/src/pages/MainFeed.tsx +++ b/src/pages/MainFeed.tsx @@ -29,6 +29,8 @@ import { useFeedActions } from "../hooks/useFeedActions"; import { useFavorites } from "../hooks/useFavorites"; import { useTranslation } from "react-i18next"; import { useProfile } from "../hooks/useProfile"; +import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger"; +import FullscreenAdOverlay from "../components/FullscreenAdOverlay"; import type { ComponentType } from "react"; // Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные» @@ -103,6 +105,15 @@ export default function MainFeed() { const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0; const searchPrompt = isSearchView && !hasSearchParams; + // Реклама показывается на главной ленте (без фильтров/городов). Эта же + // переменная включает обычные AdSlot-карточки в ленте и полноэкранную рекламу. + const showAds = activeNav === "home" && selectedCities.length === 0 && !searchQuery.trim(); + + // Полноэкранная реклама по активному скроллу — работает так же на всех + // устройствах (явных ограничений desktop-only не было). Триггер: 20 секунд + // суммарного активного скролла ленты. + const { ad: fullscreenAd, close: closeFullscreenAd } = useFullscreenAdTrigger(20, showAds); + const feed = useFeedPosts(); const myPosts = useMyPosts(user?.id); const cityFeed = useCityFeed(selectedCities); @@ -464,6 +475,7 @@ export default function MainFeed() { filteredAds={filteredAds} activeNav={activeNav} searchPrompt={searchPrompt} + showAds={showAds} liked={liked} likeCountAdj={likeCountAdj} onLike={actions.toggleLike} @@ -555,6 +567,8 @@ export default function MainFeed() { /> )} + {fullscreenAd && } + { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function getAdSessionId() { + let sessionId = localStorage.getItem(AD_SESSION_KEY); + if (!sessionId) { + sessionId = generateAdSessionId(); + localStorage.setItem(AD_SESSION_KEY, sessionId); + } + return sessionId; +} \ No newline at end of file diff --git a/src/utils/campaignStatus.js b/src/utils/campaignStatus.js new file mode 100644 index 0000000..c9a80b6 --- /dev/null +++ b/src/utils/campaignStatus.js @@ -0,0 +1,16 @@ +// Разрешённые переходы статусов кампании (зеркало логики бэкенда). +// FINISHED — конечное состояние: переходов из него нет. +export const CAMPAIGN_STATUS_TRANSITIONS = { + DRAFT: ["ACTIVE"], + ACTIVE: ["PAUSED", "FINISHED"], + PAUSED: ["ACTIVE", "FINISHED"], + FINISHED: [], +}; + +export function getNextCampaignStatuses(currentStatus) { + return CAMPAIGN_STATUS_TRANSITIONS[currentStatus] || []; +} + +export function isProcessingTranscoding(status) { + return status === "PENDING" || status === "PROCESSING"; +} \ No newline at end of file diff --git a/src/utils/withAds.js b/src/utils/withAds.js new file mode 100644 index 0000000..bde4a58 --- /dev/null +++ b/src/utils/withAds.js @@ -0,0 +1,13 @@ +export function withAds(posts, every = 10) { + if (!Array.isArray(posts)) return []; + if (every <= 0) return posts.map((data) => ({ kind: "post", data })); + + const items = []; + posts.forEach((data, index) => { + if (index > 0 && index % every === 0) { + items.push({ kind: "ad", key: `ad-${index}` }); + } + items.push({ kind: "post", data }); + }); + return items; +} \ No newline at end of file