added advertising
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user