added advertising
This commit is contained in:
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 { useLanguageToggle } from "../hooks/useLanguageToggle";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
import { useIsDesktop } from "../hooks/useIsDesktop";
|
||||
import SupportModal from "./SupportModal";
|
||||
import UploadAdModal from "./UploadAdModal";
|
||||
import ProfileAvatar from "./profile/ProfileAvatar";
|
||||
import NahodkaLogo from "./MainNahodkaLogo";
|
||||
|
||||
@@ -28,11 +31,16 @@ export default function Sidebar({
|
||||
profileData,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin } = useAuthContext();
|
||||
// Админ-пункты (реклама) показываем только в десктоп-раскладке:
|
||||
// на мобильном их не должно быть вообще (ни в drawer-меню).
|
||||
const isDesktop = useIsDesktop();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const { totalUnread } = useUnread();
|
||||
const { t } = useTranslation();
|
||||
const { language, toggleLanguage } = useLanguageToggle();
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [uploadAdOpen, setUploadAdOpen] = useState(false);
|
||||
const HelpIcon = NavIcons.help;
|
||||
|
||||
const handleNavItem = (itemKey) => {
|
||||
@@ -100,6 +108,31 @@ export default function Sidebar({
|
||||
</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>
|
||||
<div className="f-sb-bottom">
|
||||
{NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => {
|
||||
@@ -164,6 +197,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
{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 AdSlot from "../AdSlot";
|
||||
import { withAds } from "../../utils/withAds";
|
||||
|
||||
const AD_EVERY = 10;
|
||||
|
||||
export default function FeedContent({
|
||||
loading,
|
||||
@@ -17,8 +22,16 @@ export default function FeedContent({
|
||||
onImageClick,
|
||||
onLocationClick,
|
||||
onAuthRequired,
|
||||
showAds = false,
|
||||
t,
|
||||
}) {
|
||||
// Реклама вставляется только на главной ленте (showAds). useMemo — чтобы
|
||||
// при ре-рендерах (виждет-обновления, лайки и т.п.) позиции слотов не
|
||||
// пересчитывались и уже загруженные объявления не терялись.
|
||||
const feedItems = useMemo(
|
||||
() => withAds(filteredAds, showAds ? AD_EVERY : 0),
|
||||
[filteredAds, showAds]
|
||||
);
|
||||
if (loading || (citySearchActive && cityLoading)) {
|
||||
return (
|
||||
<div className="f-center">
|
||||
@@ -68,20 +81,28 @@ export default function FeedContent({
|
||||
);
|
||||
}
|
||||
|
||||
return filteredAds.map(ad => (
|
||||
<PostOnFeed
|
||||
key={ad.id}
|
||||
ad={ad}
|
||||
liked={liked.has(ad.id)}
|
||||
likeCountAdj={likeCountAdj[ad.id] || 0}
|
||||
onLike={onLike}
|
||||
onEdit={activeNav === "myads" ? onEdit : undefined}
|
||||
onDelete={activeNav === "myads" ? onDelete : undefined}
|
||||
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
|
||||
isOwner={activeNav === "myads"}
|
||||
onImageClick={onImageClick}
|
||||
onLocationClick={onLocationClick}
|
||||
onAuthRequired={onAuthRequired}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
{feedItems.map((item) =>
|
||||
item.kind === "ad" ? (
|
||||
<AdSlot key={item.key} />
|
||||
) : (
|
||||
<PostOnFeed
|
||||
key={item.data.id}
|
||||
ad={item.data}
|
||||
liked={liked.has(item.data.id)}
|
||||
likeCountAdj={likeCountAdj[item.data.id] || 0}
|
||||
onLike={onLike}
|
||||
onEdit={activeNav === "myads" ? onEdit : undefined}
|
||||
onDelete={activeNav === "myads" ? onDelete : undefined}
|
||||
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
|
||||
isOwner={activeNav === "myads"}
|
||||
onImageClick={onImageClick}
|
||||
onLocationClick={onLocationClick}
|
||||
onAuthRequired={onAuthRequired}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,12 @@ export const NavIcons = {
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</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: () => (
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user