added advertising
This commit is contained in:
13
src/App.js
13
src/App.js
@@ -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
229
src/api/adminAdsApi.js
Normal 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
92
src/api/adsApi.js
Normal 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
85
src/components/AdCard.jsx
Normal 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
65
src/components/AdSlot.jsx
Normal 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" />;
|
||||||
|
}
|
||||||
114
src/components/FullscreenAdOverlay.jsx
Normal file
114
src/components/FullscreenAdOverlay.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
397
src/components/UploadAdModal.jsx
Normal file
397
src/components/UploadAdModal.jsx
Normal 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 || "—";
|
||||||
|
}
|
||||||
361
src/components/admin/AdvertisingCreateModal.jsx
Normal file
361
src/components/admin/AdvertisingCreateModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/admin/AdvertisingEditModal.jsx
Normal file
113
src/components/admin/AdvertisingEditModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/admin/CampaignCreateModal.jsx
Normal file
69
src/components/admin/CampaignCreateModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/admin/CampaignEditModal.jsx
Normal file
83
src/components/admin/CampaignEditModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/admin/CampaignFormFields.jsx
Normal file
112
src/components/admin/CampaignFormFields.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/admin/CampaignStatusUpdateModal.jsx
Normal file
89
src/components/admin/CampaignStatusUpdateModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
17
src/hooks/useAdClickTracking.js
Normal file
17
src/hooks/useAdClickTracking.js
Normal 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(() => {});
|
||||||
|
}
|
||||||
36
src/hooks/useAdImpressionTracking.js
Normal file
36
src/hooks/useAdImpressionTracking.js
Normal 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]);
|
||||||
|
}
|
||||||
103
src/hooks/useFullscreenAdTrigger.js
Normal file
103
src/hooks/useFullscreenAdTrigger.js
Normal 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
26
src/hooks/useIsDesktop.js
Normal 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;
|
||||||
|
}
|
||||||
66
src/hooks/useTranscodingStatusPolling.js
Normal file
66
src/hooks/useTranscodingStatusPolling.js
Normal 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;
|
||||||
|
}
|
||||||
30
src/hooks/useTranscodingStatusSocket.js
Normal file
30
src/hooks/useTranscodingStatusSocket.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
492
src/pages/AdminAdsPage.jsx
Normal 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";
|
||||||
|
}
|
||||||
@@ -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
228
src/styles/admin.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
21
src/utils/adSession.js
Normal 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;
|
||||||
|
}
|
||||||
16
src/utils/campaignStatus.js
Normal file
16
src/utils/campaignStatus.js
Normal 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
13
src/utils/withAds.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user