added advertising

This commit is contained in:
SlimusMinus
2026-09-23 23:38:54 +03:00
parent 3661985a6a
commit 4e20315998
33 changed files with 3351 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
// src/App.jsx // src/App.jsx
import { useEffect, useRef } from 'react'; 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 { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext'; import { ThemeProvider } from './context/ThemeContext';
import { useAuthContext } from './context/AuthContext'; import { useAuthContext } from './context/AuthContext';
@@ -11,6 +11,7 @@ import MainFeed from './pages/MainFeed';
import ChatPage from './pages/ChatPage'; import ChatPage from './pages/ChatPage';
import ChatListPage from './pages/ChatListPage'; import ChatListPage from './pages/ChatListPage';
import ReviewsPage from './pages/ReviewsPage'; import ReviewsPage from './pages/ReviewsPage';
import AdminAdsPage from './pages/AdminAdsPage';
import './styles/chat.css'; import './styles/chat.css';
function ScrollToTop() { function ScrollToTop() {
@@ -38,6 +39,15 @@ function UnreadListener() {
return null; return null;
} }
// Защита десктоп-only админ-роутов: доступен только при isAdmin.
// Дополнительная проверка на уровне роута — defense in depth к тому,
// что пункт меню в Sidebar скрыт для не-админов.
function AdminRoute({ children }) {
const { isAdmin } = useAuthContext();
if (!isAdmin) return <Navigate to="/feed" replace />;
return children;
}
function App() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
@@ -51,6 +61,7 @@ function App() {
<Route path="/reviews" element={<ReviewsPage />} /> <Route path="/reviews" element={<ReviewsPage />} />
<Route path="/chats" element={<ChatListPage />} /> <Route path="/chats" element={<ChatListPage />} />
<Route path="/chats/:chatId" element={<ChatPage />} /> <Route path="/chats/:chatId" element={<ChatPage />} />
<Route path="/admin/ads" element={<AdminRoute><AdminAdsPage /></AdminRoute>} />
<Route path="*" element={<LandingPage />} /> <Route path="*" element={<LandingPage />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

229
src/api/adminAdsApi.js Normal file
View File

@@ -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();
}

92
src/api/adsApi.js Normal file
View File

@@ -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 };
}

85
src/components/AdCard.jsx Normal file
View File

@@ -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 (
<div ref={cardRef} className="ad-card">
<span className="ad-card__label">Реклама</span>
{ad.type === 'VIDEO' ? (
<video
ref={videoRef}
src={ad.mediaUrl}
className="ad-card__media"
muted
autoPlay
playsInline
loop
preload="auto"
onClick={handleClick}
/>
) : (
<img
src={ad.mediaUrl}
alt="Реклама"
className="ad-card__media"
onClick={handleClick}
/>
)}
{remaining > 0 ? (
<span className="ad-card__skip-timer">Закрыть через {remaining}с</span>
) : (
<button className="ad-card__close" onClick={onClose} aria-label="Закрыть рекламу">
✕
</button>
)}
</div>
);
}

65
src/components/AdSlot.jsx Normal file
View File

@@ -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 (
<div ref={slotRef} className="ad-slot">
<AdCard ad={ad} onClose={handleClose} />
</div>
);
}
// Зарезервированная высота, чтобы место под объявление не «дёргало» ленту
return <div ref={slotRef} className="ad-slot-placeholder" />;
}

View File

@@ -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 (
<div className="fs-ad-overlay">
<span className="fs-ad-label">{t('fullscreenAd.label')}</span>
<video
ref={(node) => {
videoRef.current = node;
if (node) node.muted = true; // свойство сразу при монтировании
}}
className="fs-ad-video"
src={ad.mediaUrl}
autoPlay
muted
controls={false}
playsInline
loop
preload="auto"
onClick={handleVideoClick}
/>
<button
type="button"
className="fs-ad-sound"
onClick={handleToggleSound}
aria-label={soundOn ? t('fullscreenAd.mute') : t('fullscreenAd.unmute')}
>
{soundOn ? "🔊" : "🔇"}
</button>
{remaining > 0 ? (
<span className="fs-ad-skip-timer">
{t('fullscreenAd.closeIn', { seconds: remaining })}
</span>
) : (
<button
type="button"
className="fs-ad-close"
onClick={onClose}
aria-label={t('common.close')}
>
✕
</button>
)}
</div>
);
}

View File

@@ -9,7 +9,10 @@ import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext"; import { useUnread } from "../context/UnreadContext";
import { useLanguageToggle } from "../hooks/useLanguageToggle"; import { useLanguageToggle } from "../hooks/useLanguageToggle";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthContext } from "../context/AuthContext";
import { useIsDesktop } from "../hooks/useIsDesktop";
import SupportModal from "./SupportModal"; import SupportModal from "./SupportModal";
import UploadAdModal from "./UploadAdModal";
import ProfileAvatar from "./profile/ProfileAvatar"; import ProfileAvatar from "./profile/ProfileAvatar";
import NahodkaLogo from "./MainNahodkaLogo"; import NahodkaLogo from "./MainNahodkaLogo";
@@ -28,11 +31,16 @@ export default function Sidebar({
profileData, profileData,
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin } = useAuthContext();
// Админ-пункты (реклама) показываем только в десктоп-раскладке:
// на мобильном их не должно быть вообще (ни в drawer-меню).
const isDesktop = useIsDesktop();
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread(); const { totalUnread } = useUnread();
const { t } = useTranslation(); const { t } = useTranslation();
const { language, toggleLanguage } = useLanguageToggle(); const { language, toggleLanguage } = useLanguageToggle();
const [supportOpen, setSupportOpen] = useState(false); const [supportOpen, setSupportOpen] = useState(false);
const [uploadAdOpen, setUploadAdOpen] = useState(false);
const HelpIcon = NavIcons.help; const HelpIcon = NavIcons.help;
const handleNavItem = (itemKey) => { const handleNavItem = (itemKey) => {
@@ -100,6 +108,31 @@ export default function Sidebar({
</button> </button>
); );
})} })}
{isAdmin && isDesktop && (
<>
<div className="f-sb-divider" />
<button
type="button"
className="f-sb-item"
onClick={() => {
setUploadAdOpen(true);
onClose();
}}
>
{NavIcons.megaphone()} {t('admin.uploadAd')}
</button>
<button
type="button"
className="f-sb-item"
onClick={() => {
navigate("/admin/ads");
onClose();
}}
>
{NavIcons.grid()} {t('adminPanel.campaignsNav')}
</button>
</>
)}
</nav> </nav>
<div className="f-sb-bottom"> <div className="f-sb-bottom">
{NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => { {NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => {
@@ -164,6 +197,7 @@ export default function Sidebar({
</div> </div>
</div> </div>
{supportOpen && <SupportModal onClose={() => setSupportOpen(false)} />} {supportOpen && <SupportModal onClose={() => setSupportOpen(false)} />}
{uploadAdOpen && <UploadAdModal onClose={() => setUploadAdOpen(false)} />}
</> </>
); );
} }

View File

@@ -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 (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && closeModal()}>
<div className="f-modal">
<div className="f-modal-head">
<span className="f-modal-title">{t('admin.fullscreenTitle')}</span>
<button className="f-close-btn" type="button" onClick={closeModal} disabled={busy}>
<CloseIcon />
</button>
</div>
<div className="f-m-group">
<button
type="button"
className="f-m-submit"
disabled={busy || creating}
onClick={() => {
setShowCreateForm((v) => !v);
setCreateError("");
}}
>
+ {t('adminPanel.fullscreenCreate')}
</button>
</div>
{showCreateForm && (
<div className="f-m-group">
<label className="f-m-label" htmlFor="fs-ad-url">
{t('adminPanel.targetUrl')} <span className="f-hint" style={{ display: "inline" }}>— {t('adminPanel.fullscreenUrlOptional')}</span>
</label>
<input
id="fs-ad-url"
className="f-m-field"
type="text"
placeholder="https://example.com/landing"
value={newTargetUrl}
onChange={(e) => {
setNewTargetUrl(e.target.value);
setCreateError("");
}}
disabled={creating}
/>
<div className="f-hint">{t('adminPanel.fullscreenUrlHint')}</div>
{createError && <div className="f-err-msg">{createError}</div>}
<button
type="button"
className="f-m-submit"
disabled={creating}
style={{ marginTop: 8 }}
onClick={handleCreate}
>
<span className="f-m-submit-row">
{creating && <span className="f-m-spinner" />}
{t('adminPanel.fullscreenCreateSubmit')}
</span>
</button>
</div>
)}
<div className="admp-session-hint fs-ad-hint">
{t('adminPanel.fullscreenHint')}
</div>
{activeCount > 0 && (
<div className="fs-ad-hint">{t('adminPanel.fullscreenActivateHint')}</div>
)}
{actionSuccess && <div className="f-ok-msg">{actionSuccess}</div>}
{actionError && <div className="f-err-msg">{actionError}</div>}
{uploadStepError && <div className="f-err-msg">{uploadStepError}</div>}
{loadError && <div className="f-err-msg">{loadError}</div>}
<input
ref={fileInputRef}
className="f-img-input"
type="file"
accept={VIDEO_ACCEPT}
onChange={handleFileSelected}
disabled={busy}
/>
{loading ? (
<div className="f-center admp-empty">
<div className="f-spinner" />
</div>
) : items.length === 0 ? (
<div className="admp-no-ads">{t('adminPanel.fullscreenEmpty')}</div>
) : (
<div className="admp-ads fs-ad-list">
{items.map((item) => {
const isUploading = uploadingId === item.id;
const ready = item.transcodingStatus === "READY";
const processing = isProcessingTranscoding(item.transcodingStatus);
const isActive = item.status === "ACTIVE";
return (
<div className="admp-ad fs-ad-row" key={item.id}>
<span className={`admp-badge admp-badge--${String(item.status).toLowerCase()}`}>
{statusLabel(item.status, t)}
</span>
{item.transcodingStatus && (
<span className={`admp-badge ${processing ? "admp-badge--processing" : ""}${item.transcodingStatus === "READY" ? " admp-badge--ready" : ""}${item.transcodingStatus === "FAILED" ? " admp-badge--failed" : ""}${item.transcodingStatus === "TIMEOUT" ? " admp-badge--timeout" : ""}`}>
{transcodingLabel(item.transcodingStatus, t)}
</span>
)}
<div className="admp-ad-info">
{item.targetUrl ? (
<div className="admp-ad-url">{item.targetUrl}</div>
) : (
<div className="admp-ad-sub">{t('adminPanel.fullscreenNoUrl')}</div>
)}
{!isActive && ready && (
<div className="admp-ad-sub">{t('adminPanel.fullscreenCanActivate')}</div>
)}
</div>
<div className="admp-campaign-actions">
{isUploading ? (
<span className="admp-btn admp-btn--ghost" style={{ cursor: "default" }}>
{t('adminPanel.saving')}
</span>
) : item.status === "DRAFT" && !ready ? (
<button
type="button"
className="admp-btn admp-btn--ghost"
disabled={busy || actionBusyId !== null}
onClick={() => handlePickVideo(item)}
>
{t('adminPanel.fullscreenUploadVideo')}
</button>
) : ready && !isActive ? (
<button
type="button"
className="admp-btn"
disabled={busy || actionBusyId !== null}
onClick={() => handleActivate(item)}
>
{t('adminPanel.fullscreenMakeActive')}
</button>
) : isActive ? (
<button
type="button"
className="admp-btn admp-btn--ghost"
disabled={busy || actionBusyId !== null}
onClick={() => handleDeactivate(item)}
>
{t('adminPanel.fullscreenDeactivate')}
</button>
) : null}
</div>
{isUploading && uploadProgress !== null && (
<div className="f-m-group" style={{ flexBasis: "100%" }}>
<div className="f-upload-progress">
<div className="f-upload-progress-fill" style={{ width: `${uploadProgress}%` }} />
</div>
<div className="f-upload-progress-text">{uploadProgress}%</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
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 || "—";
}

View File

@@ -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 (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adCreateTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-type">{t('adminPanel.adType')}</label>
<select
id="admp-ad-type"
className={`f-m-field${errors.type ? " err" : ""}`}
disabled={submitting}
{...register("type")}
>
<option value="IMAGE">IMAGE</option>
<option value="VIDEO">VIDEO</option>
</select>
{errors.type && <div className="f-err-msg">{errors.type.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-url">{t('adminPanel.targetUrl')}</label>
<input
id="admp-ad-url"
className={`f-m-field${errors.targetUrl ? " err" : ""}`}
type="text"
placeholder="https://example.com/landing"
disabled={submitting}
{...register("targetUrl")}
/>
{errors.targetUrl && <div className="f-err-msg">{errors.targetUrl.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-weight">{t('adminPanel.weight')}</label>
<input
id="admp-ad-weight"
className={`f-m-field${errors.weight ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="10"
disabled={submitting}
{...register("weight")}
/>
{errors.weight ? (
<div className="f-err-msg">{errors.weight.message}</div>
) : (
<div className="f-hint">{t('adminPanel.weightHint')}</div>
)}
</div>
{type === "IMAGE" ? (
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-image">{t('adminPanel.adImage')}</label>
{imagePreview ? (
<>
<div className="f-img-preview-wrap">
<img src={imagePreview} alt={imageFile?.name || ""} className="f-img-preview" />
<button
className="f-img-remove"
type="button"
onClick={handleRemoveImage}
disabled={uploadingImage || submitting}
aria-label={t('adminPanel.adImageRemove')}
>
<CloseIcon />
</button>
</div>
{imageFile && (
<div className="f-hint" style={{ marginTop: 6 }}>
{uploadingImage ? t('adminPanel.adImageUploading') : imageFile.name}
</div>
)}
</>
) : (
<label className="f-img-upload-label">
<input
ref={imageInputRef}
id="admp-ad-image"
type="file"
accept={IMAGE_ACCEPT}
onChange={handleImageChange}
className="f-img-input"
disabled={uploadingImage || submitting}
/>
<span>{t('adminPanel.adImage')} 📷</span>
</label>
)}
{imageError ? (
<div className="f-err-msg">{imageError}</div>
) : (
<div className="f-hint">{t('adminPanel.adImageHint', { maxSize: MAX_SIZE_MB })}</div>
)}
</div>
) : (
<div className="f-m-group">
<input
ref={fileInputRef}
id="admp-ad-video"
className="f-img-input"
type="file"
accept={VIDEO_ACCEPT}
onChange={handleFileChange}
disabled={submitting}
/>
<label className="f-img-upload-label" htmlFor="admp-ad-video">
📹 {file ? file.name : t('adminPanel.adVideo')}
</label>
<div className="f-hint">{t('adminPanel.adVideoHint')}</div>
</div>
)}
{progress !== null && submitting && (
<div className="f-m-group">
<div className="f-upload-progress">
<div className="f-upload-progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="f-upload-progress-text">{progress}%</div>
</div>
)}
{stepError && <div className="f-err-msg" style={{ marginTop: 4 }}>{stepError}</div>}
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button
className="f-m-submit"
type="submit"
disabled={submitting || (type === "IMAGE" && !mediaKey)}
>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.savingAd') : t('adminPanel.adCreateSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adEditTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-edit-url">{t('adminPanel.targetUrl')}</label>
<input
id="admp-ad-edit-url"
className={`f-m-field${errors.targetUrl ? " err" : ""}`}
type="text"
placeholder="https://example.com/landing"
disabled={submitting}
{...register("targetUrl")}
/>
{errors.targetUrl && <div className="f-err-msg">{errors.targetUrl.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-edit-weight">{t('adminPanel.weight')}</label>
<input
id="admp-ad-edit-weight"
className={`f-m-field${errors.weight ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="10"
disabled={submitting}
{...register("weight")}
/>
{errors.weight ? (
<div className="f-err-msg">{errors.weight.message}</div>
) : (
<div className="f-hint">{t('adminPanel.weightHint')}</div>
)}
</div>
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.savingAd') : t('adminPanel.adEditSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.campaignCreateTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<CampaignFormFields register={register} errors={errors} submitting={submitting} t={t} />
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.campaignCreateSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.campaignEditTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<CampaignFormFields register={register} errors={errors} submitting={submitting} t={t} />
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.saveCampaign')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-name">{t('adminPanel.campaignName')}</label>
<input
id="admp-camp-name"
className={`f-m-field${errors.name ? " err" : ""}`}
type="text"
maxLength={255}
placeholder="Летняя распродажа"
disabled={submitting}
{...register("name")}
/>
{errors.name && <div className="f-err-msg">{errors.name.message}</div>}
</div>
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-start">{t('adminPanel.campaignStart')}</label>
<input
id="admp-camp-start"
className={`f-m-field${errors.startDate ? " err" : ""}`}
type="datetime-local"
disabled={submitting}
{...register("startDate")}
/>
{errors.startDate && <div className="f-err-msg">{errors.startDate.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-end">{t('adminPanel.campaignEnd')}</label>
<input
id="admp-camp-end"
className={`f-m-field${errors.endDate ? " err" : ""}`}
type="datetime-local"
disabled={submitting}
{...register("endDate")}
/>
{errors.endDate && <div className="f-err-msg">{errors.endDate.message}</div>}
</div>
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-cap">{t('adminPanel.campaignCap')}</label>
<input
id="admp-camp-cap"
className={`f-m-field${errors.dailyImpressionCap ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="1000"
disabled={submitting}
{...register("dailyImpressionCap")}
/>
{errors.dailyImpressionCap ? (
<div className="f-err-msg">{errors.dailyImpressionCap.message}</div>
) : (
<div className="f-hint">{t('adminPanel.campaignCapHint')}</div>
)}
</div>
</>
);
}

View File

@@ -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 (
<div
className="f-modal-bg"
onClick={e => e.target === e.currentTarget && !submitting && onClose()}
>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adStatusTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="f-m-group">
<span className="f-m-label">{t('adminPanel.currentStatus')}</span>
<p className="admp-current-status">
<span className={`admp-badge admp-badge--${String(currentStatus).toLowerCase()}`}>
{currentStatus}
</span>
</p>
</div>
{hasTransitions ? (
<>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-status-next">{t('adminPanel.newStatus')}</label>
<select
id="admp-status-next"
className="f-m-field"
value={nextStatus}
onChange={(e) => setNextStatus(e.target.value)}
disabled={submitting}
>
{allowed.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.apply')}
</span>
</button>
</>
) : (
<p className="admp-no-transitions">{t('adminPanel.noTransitions')}</p>
)}
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,9 @@
import { useMemo } from "react";
import PostOnFeed from "../../pages/PostOnFeed"; import PostOnFeed from "../../pages/PostOnFeed";
import AdSlot from "../AdSlot";
import { withAds } from "../../utils/withAds";
const AD_EVERY = 10;
export default function FeedContent({ export default function FeedContent({
loading, loading,
@@ -17,8 +22,16 @@ export default function FeedContent({
onImageClick, onImageClick,
onLocationClick, onLocationClick,
onAuthRequired, onAuthRequired,
showAds = false,
t, t,
}) { }) {
// Реклама вставляется только на главной ленте (showAds). useMemo — чтобы
// при ре-рендерах (виждет-обновления, лайки и т.п.) позиции слотов не
// пересчитывались и уже загруженные объявления не терялись.
const feedItems = useMemo(
() => withAds(filteredAds, showAds ? AD_EVERY : 0),
[filteredAds, showAds]
);
if (loading || (citySearchActive && cityLoading)) { if (loading || (citySearchActive && cityLoading)) {
return ( return (
<div className="f-center"> <div className="f-center">
@@ -68,12 +81,17 @@ export default function FeedContent({
); );
} }
return filteredAds.map(ad => ( return (
<>
{feedItems.map((item) =>
item.kind === "ad" ? (
<AdSlot key={item.key} />
) : (
<PostOnFeed <PostOnFeed
key={ad.id} key={item.data.id}
ad={ad} ad={item.data}
liked={liked.has(ad.id)} liked={liked.has(item.data.id)}
likeCountAdj={likeCountAdj[ad.id] || 0} likeCountAdj={likeCountAdj[item.data.id] || 0}
onLike={onLike} onLike={onLike}
onEdit={activeNav === "myads" ? onEdit : undefined} onEdit={activeNav === "myads" ? onEdit : undefined}
onDelete={activeNav === "myads" ? onDelete : undefined} onDelete={activeNav === "myads" ? onDelete : undefined}
@@ -83,5 +101,8 @@ export default function FeedContent({
onLocationClick={onLocationClick} onLocationClick={onLocationClick}
onAuthRequired={onAuthRequired} onAuthRequired={onAuthRequired}
/> />
)); )
)}
</>
);
} }

View File

@@ -56,6 +56,12 @@ export const NavIcons = {
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
), ),
megaphone: () => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m3 11 18-5v12L3 14v-3z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
</svg>
),
plus: () => ( plus: () => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"> <svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="6" /> <rect x="3" y="3" width="18" height="18" rx="6" />

View File

@@ -65,6 +65,10 @@ export function AuthProvider({ children }) {
user, user,
token, token,
isAuthenticated: !!token, isAuthenticated: !!token,
// Вычисляемое поле вместо повторяющегося user?.role === "ADMIN"
// в каждом компоненте. Валидация роли — на бэкенде; здесь только
// UI-флаг для показа админ-элементов.
isAdmin: !!user && user.role === "ADMIN",
login, login,
logout, logout,
updateUser, updateUser,

View File

@@ -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(() => {});
}

View File

@@ -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]);
}

View File

@@ -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 };
}

26
src/hooks/useIsDesktop.js Normal file
View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -226,6 +226,121 @@
"footPrivacy": "Privacy", "footPrivacy": "Privacy",
"footSupport": "Support" "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": { "landing": {
"eyebrow": "Service for finding lost pets and items", "eyebrow": "Service for finding lost pets and items",
"heroTitle": "We believe that everything lost can be found.", "heroTitle": "We believe that everything lost can be found.",
@@ -379,6 +494,12 @@
"common": { "common": {
"close": "Close" "close": "Close"
}, },
"fullscreenAd": {
"label": "Ad",
"closeIn": "Close in {{seconds}}s",
"mute": "Mute sound",
"unmute": "Unmute sound"
},
"viewer": { "viewer": {
"goToImage": "Go to image {{number}}" "goToImage": "Go to image {{number}}"
}, },

View File

@@ -227,6 +227,121 @@
"footPrivacy": "Конфиденциальность", "footPrivacy": "Конфиденциальность",
"footSupport": "Поддержка" "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": { "landing": {
"eyebrow": "Сервис поиска пропавших питомцев и вещей", "eyebrow": "Сервис поиска пропавших питомцев и вещей",
"heroTitle": "Верим, что всё потерянное можно найти.", "heroTitle": "Верим, что всё потерянное можно найти.",
@@ -380,6 +495,12 @@
"common": { "common": {
"close": "Закрыть" "close": "Закрыть"
}, },
"fullscreenAd": {
"label": "Реклама",
"closeIn": "Закрыть через {{seconds}}с",
"mute": "Выключить звук",
"unmute": "Включить звук"
},
"viewer": { "viewer": {
"goToImage": "Перейти к изображению {{number}}" "goToImage": "Перейти к изображению {{number}}"
}, },

492
src/pages/AdminAdsPage.jsx Normal file
View File

@@ -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 <Navigate to="/feed" replace />;
return (
<div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}>
<Sidebar
open={sidebarOpen}
activeNav="home"
onClose={() => 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}
/>
<div className="f-main">
<AppTopBar
onMenuClick={() => setSidebarOpen((v) => !v)}
isAuthenticated={isAuthenticated}
profileData={profileData}
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
hideWordmark
centerContent={<h1 className="admp-title">{t('adminPanel.pageTitle')}</h1>}
/>
<div className="admp">
<div className="admp-inner">
<div className="admp-toolbar">
<div>
<div className="admp-session-hint">
{campaignsLoading
? t('adminPanel.campaignsLoading')
: t('adminPanel.persistHint')}
</div>
{campaignsLoadError && <div className="f-err-msg admp-load-error">{campaignsLoadError}</div>}
</div>
<button
type="button"
className="f-post-btn"
onClick={() => modals.open("campaign-create")}
>
<span>+</span> {t('adminPanel.createCampaign')}
</button>
</div>
{!campaignsLoading && campaigns.length === 0 && (
<div className="f-center admp-empty">
<div className="f-center-icon">📣</div>
<div className="f-center-title">{t('adminPanel.noCampaigns')}</div>
<div className="f-center-sub">{t('adminPanel.noCampaignsSub')}</div>
</div>
)}
{campaigns.map((campaign) => {
const allowedNext = getNextCampaignStatuses(campaign.status);
return (
<div className="admp-campaign" key={campaign.id}>
<div className="admp-campaign-head">
<div className="admp-campaign-name">
{campaign.name}
<span className={`admp-badge admp-badge--${String(campaign.status).toLowerCase()}`}>
{campaign.status}
</span>
</div>
<div className="admp-campaign-actions">
<button
type="button"
className="admp-btn admp-btn--ghost"
onClick={() => handleToggleCampaign(campaign.id)}
>
{expandedCampaignIds.has(campaign.id)
? t('adminPanel.hideAds')
: t('adminPanel.showAds')}
</button>
<button
type="button"
className="admp-btn admp-btn--ghost"
onClick={() => modals.open("campaign-edit", { campaign })}
>
{t('adminPanel.editCampaign')}
</button>
{allowedNext.length > 0 && (
<button
type="button"
className="admp-btn admp-btn--ghost"
onClick={() =>
modals.open("campaign-status-update", {
campaignId: campaign.id,
currentStatus: campaign.status,
})
}
>
{t('adminPanel.changeStatus')}
</button>
)}
<button
type="button"
className="admp-btn"
onClick={() => modals.open("advertising-create", { campaignId: campaign.id })}
>
+ {t('adminPanel.addAd')}
</button>
</div>
</div>
<div className="admp-meta">
<span>{t('adminPanel.dates')}: <strong>{formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)}</strong></span>
<span>{t('adminPanel.dailyCap')}: <strong>{campaign.dailyImpressionCap}</strong></span>
</div>
{expandedCampaignIds.has(campaign.id) && (
<>
<div className="admp-section-title">{t('adminPanel.ads')}</div>
{campaign.adsLoading ? (
<div className="admp-no-ads">{t('adminPanel.adsLoading')}</div>
) : campaign.adsLoadError ? (
<div className="f-err-msg admp-load-error">{campaign.adsLoadError}</div>
) : campaign.ads.length === 0 ? (
<div className="admp-no-ads">{t('adminPanel.noAds')}</div>
) : (
<div className="admp-ads">
{campaign.ads.map((ad) => (
<div className="admp-ad" key={ad.id}>
<span className="admp-ad-type">{ad.type}</span>
<div className="admp-ad-info">
<div className="admp-ad-url">{ad.targetUrl}</div>
<div className="admp-ad-sub">
{t('adminPanel.weight')}: {ad.weight}
</div>
</div>
<span className={`admp-badge admp-badge--${transcodingBadgeClass(ad)}`}>
{transcodingBadgeText(ad, t)}
</span>
<button
type="button"
className="admp-btn admp-btn--ghost admp-ad-edit"
onClick={() => modals.open("advertising-edit", { ad })}
>
{t('adminPanel.editAdvertising')}
</button>
</div>
))}
</div>
)}
</>
)}
</div>
);
})}
</div>
</div>
</div>
{modals.isOpen("campaign-create") && isAdmin && (
<CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} />
)}
{modals.isOpen("campaign-edit") && isAdmin && (
<CampaignEditModal
campaign={modals.modal.payload.campaign}
onClose={modals.close}
onUpdated={handleCampaignUpdated}
/>
)}
{modals.isOpen("advertising-create") && isAdmin && (
<AdvertisingCreateModal
campaignId={modals.modal.payload.campaignId}
onClose={modals.close}
onCreated={handleAdCreated}
/>
)}
{modals.isOpen("advertising-edit") && isAdmin && (
<AdvertisingEditModal
ad={modals.modal.payload.ad}
onClose={modals.close}
onUpdated={handleAdUpdated}
/>
)}
{modals.isOpen("campaign-status-update") && isAdmin && (
<CampaignStatusUpdateModal
campaignId={modals.modal.payload.campaignId}
currentStatus={modals.modal.payload.currentStatus}
onClose={modals.close}
onUpdated={handleStatusUpdated}
/>
)}
{modals.isOpen("profile") && isAuthenticated && (
<ProfileModal onClose={modals.close} initialData={profileData} />
)}
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
{toast && (
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
)}
</div>
);
}
/** Бейдж статуса транскодинга объявления. */
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";
}

View File

@@ -29,6 +29,8 @@ import { useFeedActions } from "../hooks/useFeedActions";
import { useFavorites } from "../hooks/useFavorites"; import { useFavorites } from "../hooks/useFavorites";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger";
import FullscreenAdOverlay from "../components/FullscreenAdOverlay";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
// Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные» // Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные»
@@ -103,6 +105,15 @@ export default function MainFeed() {
const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0; const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0;
const searchPrompt = isSearchView && !hasSearchParams; 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 feed = useFeedPosts();
const myPosts = useMyPosts(user?.id); const myPosts = useMyPosts(user?.id);
const cityFeed = useCityFeed(selectedCities); const cityFeed = useCityFeed(selectedCities);
@@ -464,6 +475,7 @@ export default function MainFeed() {
filteredAds={filteredAds} filteredAds={filteredAds}
activeNav={activeNav} activeNav={activeNav}
searchPrompt={searchPrompt} searchPrompt={searchPrompt}
showAds={showAds}
liked={liked} liked={liked}
likeCountAdj={likeCountAdj} likeCountAdj={likeCountAdj}
onLike={actions.toggleLike} onLike={actions.toggleLike}
@@ -555,6 +567,8 @@ export default function MainFeed() {
/> />
)} )}
{fullscreenAd && <FullscreenAdOverlay ad={fullscreenAd} onClose={closeFullscreenAd} />}
<BottomNav <BottomNav
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"} activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
hasFilters={hasActiveFilters} hasFilters={hasActiveFilters}

228
src/styles/admin.css Normal file
View File

@@ -0,0 +1,228 @@
/* ── Admin: Рекламные кампании (desktop-only) ── */
.admp {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 28px 32px 48px;
background: var(--bg-app);
font-family: var(--font);
}
.admp-inner {
max-width: 860px;
margin: 0 auto;
}
.admp-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.admp-title {
margin: 0;
font-size: 1.1rem;
font-weight: 800;
color: var(--text-primary);
}
.admp-session-hint {
flex: 1;
font-size: .78rem;
color: var(--muted);
}
.admp-load-error {
flex: 1;
margin-top: 4px;
font-size: .78rem;
}
.admp-empty {
margin-top: 36px;
}
/* ── Карточка кампании ── */
.admp-campaign {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 16px;
}
.admp-campaign-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.admp-campaign-name {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 1.05rem;
font-weight: 800;
color: var(--text-primary);
}
.admp-campaign-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.admp-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: .82rem;
color: var(--muted);
margin-bottom: 14px;
}
.admp-meta strong {
color: var(--text-primary);
font-weight: 700;
}
.admp-section-title {
font-size: .72rem;
font-weight: 800;
color: var(--muted);
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 8px;
}
.admp-no-ads {
font-size: .8rem;
color: var(--muted);
padding: 8px 0;
}
/* ── Строка объявления ── */
.admp-ads {
display: flex;
flex-direction: column;
gap: 8px;
}
.admp-ad {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 14px;
}
.admp-ad-type {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 8px;
font-size: .68rem;
font-weight: 800;
letter-spacing: .5px;
background: rgba(var(--teal-rgb), .12);
color: var(--teal);
}
.admp-ad-info {
flex: 1;
min-width: 0;
}
.admp-ad-url {
color: var(--text-primary);
font-size: .85rem;
word-break: break-all;
}
.admp-ad-sub {
color: var(--muted);
font-size: .75rem;
margin-top: 2px;
}
/* ── Бейдж статусов ── */
.admp-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: .68rem;
font-weight: 800;
letter-spacing: .4px;
text-transform: uppercase;
line-height: 1.4;
}
.admp-badge--draft { background: rgba(0,0,0,.06); color: var(--muted); }
.admp-badge--active { background: rgba(var(--teal-rgb), .13); color: var(--teal); }
.admp-badge--inactive { background: rgba(0,0,0,.08); color: var(--muted); }
.admp-badge--paused { background: rgba(250,126,30,.14); color: #fa7e1e; }
.admp-badge--finished { background: rgba(0,0,0,.08); color: var(--muted); }
.admp-badge--ready { background: rgba(var(--teal-rgb), .13); color: var(--teal); }
.admp-badge--processing { background: rgba(250,126,30,.14); color: #fa7e1e; }
.admp-badge--failed { background: rgba(237,73,86,.12); color: var(--err); }
.admp-badge--timeout { background: rgba(0,0,0,.08); color: var(--muted); }
/* ── Кнопки админ-панели ── */
.admp-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 10px;
background: rgba(var(--teal-rgb), .1);
border: 1px solid rgba(var(--teal-rgb), .28);
color: var(--teal);
font-family: var(--font);
font-size: .78rem;
font-weight: 800;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.admp-btn:hover:not(:disabled) {
background: rgba(var(--teal-rgb), .16);
}
.admp-btn:disabled {
opacity: .5;
cursor: not-allowed;
}
.admp-btn--ghost {
background: var(--bg-input);
border-color: var(--border);
color: var(--text-primary);
}
.admp-btn--ghost:hover:not(:disabled) {
border-color: var(--teal);
color: var(--teal);
}
.admp-ad-edit {
flex-shrink: 0;
padding: 5px 10px;
font-size: .72rem;
}
/* ── Смена статуса ── */
.admp-current-status { margin: 0 0 6px; }
.admp-no-transitions {
font-size: .85rem;
color: var(--muted);
line-height: 1.5;
}
/* ── Полноэкранная реклама (UploadAdModal) ── */
.fs-ad-hint {
font-size: .72rem;
color: var(--muted);
line-height: 1.5;
margin: 8px 0 4px;
}
.fs-ad-list {
margin-top: 4px;
}
.fs-ad-row {
flex-wrap: wrap;
}
.fs-ad-row .admp-campaign-actions {
margin-left: auto;
}
.fs-ad-row .f-upload-progress-text {
text-align: left;
}

View File

@@ -444,3 +444,130 @@ button.f-card-reward-btn:focus-visible {
font-size: .95rem; font-size: .95rem;
} }
} }
/* ── Advertisement card ── */
.ad-slot-placeholder {
min-height: 250px; /* высота типичного ad-card — резервируем место, чтобы не дёргалась лента */
}
.ad-card {
position: relative;
border-radius: 12px;
overflow: hidden;
margin: 12px 0;
}
.ad-card__label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
z-index: 2;
}
.ad-card__media {
width: 100%;
height: auto;
display: block;
cursor: pointer;
}
.ad-card__close,
.ad-card__skip-timer {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 12px;
z-index: 2;
}
.ad-card__skip-timer {
border-radius: 14px;
width: auto;
padding: 0 10px;
}
/* ── Полноэкранная реклама (триггер по активному скроллу ленты) ── */
.fs-ad-overlay {
position: fixed;
inset: 0;
z-index: 300; /* выше всех модалок (.f-viewer-bg = 200, .f-modal-bg = 100) */
background: #000;
display: flex;
align-items: center;
justify-content: center;
animation: mfadeIn .2s ease;
cursor: pointer;
}
.fs-ad-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.fs-ad-label {
position: absolute;
top: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
z-index: 2;
}
.fs-ad-close,
.fs-ad-skip-timer {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: none;
z-index: 2;
}
.fs-ad-skip-timer {
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
}
.fs-ad-close {
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 16px;
cursor: pointer;
transition: background .15s;
}
.fs-ad-close:hover {
background: rgba(0, 0, 0, 0.85);
}
.fs-ad-sound {
position: absolute;
bottom: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
font-size: 16px;
cursor: pointer;
z-index: 2;
transition: background .15s;
}
.fs-ad-sound:hover {
background: rgba(0, 0, 0, 0.85);
}

View File

@@ -27,7 +27,8 @@
/* Form Fields */ /* Form Fields */
.f-m-group { margin-bottom: 14px; } .f-m-group { margin-bottom: 14px; }
.f-m-row { display: flex; gap: 10px; } .f-m-row { display: flex; gap: 10px; }
.f-m-row .f-m-group { flex: 1; } .f-m-row .f-m-group { flex: 1; min-width: 0; }
.f-m-row .f-m-field { width: 100%; min-width: 0; }
.f-m-label { .f-m-label {
display: block; color: var(--muted); display: block; color: var(--muted);
font-size: .72rem; font-weight: 700; letter-spacing: .6px; font-size: .72rem; font-weight: 700; letter-spacing: .6px;
@@ -315,6 +316,22 @@ select.f-m-field::selection { background: transparent; }
border-radius: 50%; animation: spin .7s linear infinite; border-radius: 50%; animation: spin .7s linear infinite;
} }
/* ── Upload Ad Modal ── */
.f-upload-progress {
height: 8px; border-radius: 4px; overflow: hidden;
background: var(--bg-input); border: 1px solid var(--border);
}
.f-upload-progress-fill {
height: 100%; background: var(--teal);
transition: width .15s ease;
}
.f-upload-progress-text {
font-size: .72rem; color: var(--muted); margin-top: 4px; text-align: right;
}
.f-ok-msg {
font-size: .82rem; color: var(--teal); margin-top: 8px;
}
/* ── Support Modal ── */ /* ── Support Modal ── */
.f-support-body { .f-support-body {
padding: 0 4px; padding: 0 4px;

21
src/utils/adSession.js Normal file
View File

@@ -0,0 +1,21 @@
const AD_SESSION_KEY = "ad_session_id";
function generateAdSessionId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
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;
}

View File

@@ -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";
}

13
src/utils/withAds.js Normal file
View File

@@ -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;
}