added advertising

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

View File

@@ -0,0 +1,17 @@
import { API_URL } from '../config/authConstants';
import { getAdSessionId } from '../utils/adSession';
import { apiFetch } from '../api/apiClient';
export function trackAdClick(advertisingId) {
const payload = JSON.stringify({
advertisingId,
sessionId: getAdSessionId(),
});
apiFetch(`${API_URL}/api/ads/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => {});
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { API_URL } from '../config/authConstants';
import { getAdSessionId } from '../utils/adSession';
import { apiFetch } from '../api/apiClient';
export function useAdImpressionTracking(ref, advertisingId) {
useEffect(() => {
const node = ref.current;
if (!node || !advertisingId) return;
let sent = false;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || sent) return;
sent = true;
observer.disconnect();
const payload = JSON.stringify({
advertisingId,
sessionId: getAdSessionId(),
});
apiFetch(`${API_URL}/api/ads/impression`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true, // отправка при уходе со страницы, как sendBeacon
}).catch(() => {});
},
{ threshold: 0.25 }
);
observer.observe(node);
return () => observer.disconnect();
}, [ref, advertisingId]);
}

View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getFullscreenAd, normalizeMediaUrl } from "../api/adsApi";
const SESSION_KEY = "fullscreenAdShown";
const SCROLL_PAUSE_MS = 2000;
/**
* useFullscreenAdTrigger — ловит момент, когда пользователь суммарно
* активно проскроллил ленту `triggerAfterSeconds` секунд, и один раз за сессию
* запрашивает полноэкранную рекламу.
*
* «Активный скролл»: время накапливается только между последовательными
* scroll-событиями, если между ними прошло не больше 2 секунд; иначе это пауза,
* и время через неё не накапливается.
*
* Результат:
* - GET /api/fullscreen-ad → 200: оверлей с рекламой, sessionStorage
* "fullscreenAdShown" ставится, отслеживание останавливается;
* - → 204: реклама не настроена — помечаем сессию и больше не запрашиваем;
* - иные ошибки: не помечаем сессию, накопление продолжается (повтор на том же
* триггере позже).
*
* Скролл не «баблится», но на стадии capture ловится у любого вложенного
* контейнера (лента скроллится внутри .f-feed-scroll).
*
* @param {number} [triggerAfterSeconds=60]
* @param {boolean} [enabled=true] — например, «реклама включена в ленте»
* @returns {{ ad: object|null, close: () => void }}
*/
export function useFullscreenAdTrigger(triggerAfterSeconds = 20, enabled = true) {
const [ad, setAd] = useState(null);
const triggerMsRef = useRef(Math.max(1, triggerAfterSeconds) * 1000);
useEffect(() => {
triggerMsRef.current = Math.max(1, triggerAfterSeconds) * 1000;
}, [triggerAfterSeconds]);
const close = useCallback(() => setAd(null), []);
useEffect(() => {
if (!enabled) {
console.info(`[fullscreen-ad] disabled (showAds=${enabled}), not tracking scroll`);
return;
}
if (sessionStorage.getItem(SESSION_KEY)) {
console.info(`[fullscreen-ad] skipped: session already marked (${SESSION_KEY})`);
return;
}
let accumulatedMs = 0;
let lastScrollAt = null;
let fired = false;
const fire = async () => {
if (fired) return;
fired = true;
accumulatedMs = 0;
console.info(`[fullscreen-ad] scroll threshold reached (${triggerMsRef.current}ms), firing`);
try {
console.info("[fullscreen-ad] GET /api/fullscreen-ad");
const res = await getFullscreenAd();
if (res.status === 204) {
// Реклама не настроена — в этой сессии больше не пробуем
console.info("[fullscreen-ad] 204 No Content -> marking session, no retry");
sessionStorage.setItem(SESSION_KEY, "1");
} else if (res.ok) {
const data = await res.json();
if (data.mediaUrl) data.mediaUrl = normalizeMediaUrl(data.mediaUrl);
console.info("[fullscreen-ad] 200 OK, mediaUrl=", data.mediaUrl, "targetUrl=", data.targetUrl);
sessionStorage.setItem(SESSION_KEY, "1");
setAd(data);
} else {
// Ошибка сервера — не помечаем сессию, попробуем при следующем триггере
console.warn(`[fullscreen-ad] non-200 response: ${res.status}`);
fired = false;
}
} catch (error) {
console.warn("[fullscreen-ad] request failed:", error);
fired = false;
}
};
const onScroll = () => {
if (fired) return;
const now = performance.now();
if (lastScrollAt !== null) {
const delta = now - lastScrollAt;
if (delta <= SCROLL_PAUSE_MS) accumulatedMs += delta;
}
lastScrollAt = now;
if (accumulatedMs >= triggerMsRef.current) {
fire();
}
};
window.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => window.removeEventListener("scroll", onScroll, { capture: true });
}, [enabled]);
return { ad, close };
}

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

@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
// Тот же брейкпоинт, что использует sidebar.css: на >=900px левая навигация
// становится статичной (десктоп-раскладка), ниже — мобильная.
const DESKTOP_QUERY = "(min-width: 900px)";
function matchesDesktop() {
return typeof window !== "undefined" && window.matchMedia(DESKTOP_QUERY).matches;
}
/**
* Хук «шире 900px». Используется для десктоп-only страниц (админ-панель):
* весь связанный UI не показывается на мобильной раскладке вообще.
*/
export function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState(matchesDesktop);
useEffect(() => {
const mql = window.matchMedia(DESKTOP_QUERY);
const onChange = (e) => setIsDesktop(e.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return isDesktop;
}

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef } from 'react';
import { apiFetch } from '../api/apiClient';
import { API_URL } from '../config/authConstants';
const TERMINAL_STATUSES = ['READY', 'FAILED'];
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_ATTEMPTS = 100;
/**
* Поллинг статуса транскодинга медиа.
* @param {number|string} advertisingId — id сущности
* @param {boolean} enabled — включать поллинг
* @param {string} [path="ads"] — сегмент пути (например, "ads" или "fullscreen-ads")
* @returns {string} транскодинг-статус и его производные (PROCESSING/READY/FAILED/TIMEOUT)
*/
export function useTranscodingStatusPolling(advertisingId, enabled, path = "ads") {
const [status, setStatus] = useState('PROCESSING');
const attemptsRef = useRef(0);
useEffect(() => {
if (!enabled || !advertisingId) return;
attemptsRef.current = 0;
let cancelled = false;
let timeoutId;
const poll = async () => {
if (cancelled) return;
try {
const res = await apiFetch(`${API_URL}/api/v1/admin/${path}/${advertisingId}/status`);
if (!res.ok) {
throw new Error(`Status check failed: ${res.status}`);
}
const data = await res.json();
if (cancelled) return;
setStatus(data.transcodingStatus);
attemptsRef.current += 1;
const isTerminal = TERMINAL_STATUSES.includes(data.transcodingStatus);
const exceededAttempts = attemptsRef.current >= MAX_POLL_ATTEMPTS;
if (!isTerminal && !exceededAttempts) {
timeoutId = setTimeout(poll, POLL_INTERVAL_MS);
} else if (exceededAttempts && !isTerminal) {
setStatus('TIMEOUT');
}
} catch (err) {
console.warn('Status poll failed, retrying', err);
timeoutId = setTimeout(poll, POLL_INTERVAL_MS);
}
};
poll();
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [advertisingId, enabled, path]);
return status;
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { useStompClient } from './useStompClient'; // ваш существующий хук из чата
/**
* STOMP/WebSocket-подписка на статус транскодинга медиа.
* @param {number|string} advertisingId — id сущности
* @param {boolean} enabled — включать подписку
* @param {string} [path="ads"] — сегмент пути (например, "ads" или "fullscreen-ads")
* @returns {string} транскодинг-статус
*/
export function useTranscodingStatusSocket(advertisingId, enabled, path = "ads") {
const [status, setStatus] = useState('PROCESSING');
const stompClient = useStompClient();
useEffect(() => {
if (!enabled || !advertisingId || !stompClient?.connected) return;
const subscription = stompClient.subscribe(
`/topic/${path}/${advertisingId}/status`,
(message) => {
const payload = JSON.parse(message.body);
setStatus(payload.transcodingStatus);
}
);
return () => subscription.unsubscribe();
}, [advertisingId, enabled, path, stompClient]);
return status;
}