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

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

@@ -0,0 +1,85 @@
import { useState, useEffect, useRef } from 'react';
import { trackAdClick } from '../hooks/useAdClickTracking';
import { useAdImpressionTracking } from '../hooks/useAdImpressionTracking';
const SKIP_DELAY_SECONDS = 5;
export default function AdCard({ ad, onClose }) {
const [remaining, setRemaining] = useState(SKIP_DELAY_SECONDS);
const cardRef = useRef(null);
const videoRef = useRef(null);
useAdImpressionTracking(cardRef, ad.advertisingId);
useEffect(() => {
if (remaining <= 0) return;
const timerId = setTimeout(() => setRemaining((r) => r - 1), 1000);
return () => clearTimeout(timerId);
}, [remaining]);
// Пауза видео, когда карточка уходит из вьюпорта — не жрёт ресурсы за экраном
useEffect(() => {
const videoNode = videoRef.current;
const cardNode = cardRef.current;
if (!videoNode || !cardNode) return;
// Свойство muted, а не только HTML-атрибут — критично для iOS:
// без него браузер считает autoplay «со звуком» и блокирует его.
videoNode.muted = true;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
videoNode.muted = true;
videoNode.play().catch(() => {}); // play() может отклоняться браузером, это ок
} else {
videoNode.pause();
}
},
{ threshold: 0.25 }
);
observer.observe(cardNode);
return () => observer.disconnect();
}, []);
const handleClick = () => {
trackAdClick(ad.advertisingId);
window.open(ad.targetUrl, '_blank', 'noopener,noreferrer');
};
return (
<div ref={cardRef} className="ad-card">
<span className="ad-card__label">Реклама</span>
{ad.type === 'VIDEO' ? (
<video
ref={videoRef}
src={ad.mediaUrl}
className="ad-card__media"
muted
autoPlay
playsInline
loop
preload="auto"
onClick={handleClick}
/>
) : (
<img
src={ad.mediaUrl}
alt="Реклама"
className="ad-card__media"
onClick={handleClick}
/>
)}
{remaining > 0 ? (
<span className="ad-card__skip-timer">Закрыть через {remaining}с</span>
) : (
<button className="ad-card__close" onClick={onClose} aria-label="Закрыть рекламу">
✕
</button>
)}
</div>
);
}

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

@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react';
import { API_URL } from '../config/authConstants';
import { getAccessToken } from '../api/apiClient';
import { normalizeMediaUrl } from '../api/adsApi';
import AdCard from './AdCard';
export default function AdSlot() {
const slotRef = useRef(null);
const [ad, setAd] = useState(null);
const [closed, setClosed] = useState(false);
useEffect(() => {
if (closed) return;
const node = slotRef.current;
if (!node) return;
let cancelled = false;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
const token = getAccessToken();
fetch(`${API_URL}/api/ads/next`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
.then((res) => {
// 204 No Content — свободных объявлений нет, оставляем пустой слот
if (res.status === 204) return null;
if (!res.ok) throw new Error(`Error ${res.status}`);
return res.json();
})
.then((data) => {
if (!cancelled && data) {
if (data.mediaUrl) data.mediaUrl = normalizeMediaUrl(data.mediaUrl);
setAd(data);
}
})
.catch(() => {});
},
{ rootMargin: '200px' }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [closed]);
const handleClose = () => setClosed(true);
if (closed) return null;
if (ad) {
return (
<div ref={slotRef} className="ad-slot">
<AdCard ad={ad} onClose={handleClose} />
</div>
);
}
// Зарезервированная высота, чтобы место под объявление не «дёргало» ленту
return <div ref={slotRef} className="ad-slot-placeholder" />;
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
const SKIP_DELAY_SECONDS = 5;
/**
* FullscreenAdOverlay — полноэкранный оверлей поверх всего интерфейса
* (z-index выше всех модалок). Кнопка закрытия появляется через 5 секунд
* (тот же паттерн таймера, что и в AdCard). Клик по видео открывает targetUrl
* в новой вкладке (если задан), не закрывая оверлей.
*
* Звук: мобильные браузеры блокируют autoplay со звуком (и останавливают видео
* при попытке программного unmute), поэтому стартуем строго muted — автоплей
* гарантирован на любом устройстве. Звук включается только вручную кнопкой 🔊.
*/
export default function FullscreenAdOverlay({ ad, onClose }) {
const { t } = useTranslation();
const [remaining, setRemaining] = useState(SKIP_DELAY_SECONDS);
const [soundOn, setSoundOn] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (remaining <= 0) return;
const timerId = setTimeout(() => setRemaining((r) => r - 1), 1000);
return () => clearTimeout(timerId);
}, [remaining]);
// Автоплей: снимаем muted как свойство ДО старта (атрибут на iOS не всегда
// применяется вовремя), и дополнительно пробуем play() ещё раз, когда
// браузер загрузил данные/может воспроизвести — это покрывает случаи,
// когда первый play() отклонился «NotReady»/«Abort". Никаких автоматических
// размуток — они останавливают видео на мобильных.
useEffect(() => {
const videoNode = videoRef.current;
if (!videoNode) return;
videoNode.muted = true;
const tryPlay = () => {
videoNode.muted = true;
const promise = videoNode.play();
if (promise) promise.catch(() => {});
};
tryPlay();
videoNode.addEventListener("loadeddata", tryPlay);
videoNode.addEventListener("canplay", tryPlay);
return () => {
videoNode.removeEventListener("loadeddata", tryPlay);
videoNode.removeEventListener("canplay", tryPlay);
};
}, []);
const handleVideoClick = () => {
videoRef.current?.play().catch(() => {});
if (ad.targetUrl) {
window.open(ad.targetUrl, "_blank", "noopener,noreferrer");
}
};
const handleToggleSound = (e) => {
e.stopPropagation();
const videoNode = videoRef.current;
if (!videoNode) return;
const nextMuted = !videoNode.muted;
videoNode.muted = nextMuted;
videoNode.play().catch(() => {});
setSoundOn(!nextMuted);
};
return (
<div className="fs-ad-overlay">
<span className="fs-ad-label">{t('fullscreenAd.label')}</span>
<video
ref={(node) => {
videoRef.current = node;
if (node) node.muted = true; // свойство сразу при монтировании
}}
className="fs-ad-video"
src={ad.mediaUrl}
autoPlay
muted
controls={false}
playsInline
loop
preload="auto"
onClick={handleVideoClick}
/>
<button
type="button"
className="fs-ad-sound"
onClick={handleToggleSound}
aria-label={soundOn ? t('fullscreenAd.mute') : t('fullscreenAd.unmute')}
>
{soundOn ? "🔊" : "🔇"}
</button>
{remaining > 0 ? (
<span className="fs-ad-skip-timer">
{t('fullscreenAd.closeIn', { seconds: remaining })}
</span>
) : (
<button
type="button"
className="fs-ad-close"
onClick={onClose}
aria-label={t('common.close')}
>
✕
</button>
)}
</div>
);
}

View File

@@ -9,7 +9,10 @@ import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext";
import { 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)} />}
</>
);
}

View File

@@ -0,0 +1,397 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "./icons/CloseIcon";
import {
getFullscreenAds,
createFullscreenAd,
presignFullscreenVideoUpload,
confirmFullscreenVideoUpload,
activateFullscreenAd,
deactivateFullscreenAd,
} from "../api/adminAdsApi";
import { putWithProgress } from "../api/adsApi";
import { useTranscodingStatusPolling } from "../hooks/useTranscodingStatusPolling";
import { isProcessingTranscoding } from "../utils/campaignStatus";
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
/**
* Управление полноэкранной рекламой (список роликов).
*
* - GET /api/v1/admin/fullscreen-ads — список всех роликов сразу;
* - POST /api/v1/admin/fullscreen-ads { targetUrl } — создание ролика
* (targetUrl необязателен);
* - загрузка видео для DRAFT-ролика — тот же трёхшаговый флоу, что у рекламы
* кампаний, но на путях /api/v1/admin/fullscreen-ads/{id}/video/*
* (presign-upload → PUT в MinIO → confirm-upload → поллинг статуса
* транскодинга до READY/FAILED);
* - PATCH /activate и /deactivate для смены активного ролика.
*/
export default function UploadAdModal({ onClose }) {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const pendingUploadIdRef = useRef(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [showCreateForm, setShowCreateForm] = useState(false);
const [newTargetUrl, setNewTargetUrl] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [uploadingId, setUploadingId] = useState(null);
const [uploadProgress, setUploadProgress] = useState(null);
const [uploadStepError, setUploadStepError] = useState("");
const [actionBusyId, setActionBusyId] = useState(null);
const [actionError, setActionError] = useState("");
const [actionSuccess, setActionSuccess] = useState("");
const busy = uploadingId !== null;
const reload = useCallback(async () => {
setLoadError("");
try {
const list = await getFullscreenAds();
setItems(list || []);
} catch {
setLoadError(t("adminPanel.fullscreenLoadError"));
}
}, [t]);
// Список роликов при монтировании
useEffect(() => {
let cancelled = false;
setLoading(true);
getFullscreenAds()
.then((list) => {
if (cancelled) return;
setItems(list || []);
})
.catch(() => {
if (cancelled) return;
setLoadError(t("adminPanel.fullscreenLoadError"));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [t]);
// Поллинг транскодинга загружаемого ролика (до READY/FAILED/TIMEOUT)
const pollStatus = useTranscodingStatusPolling(uploadingId, uploadingId !== null, "fullscreen-ads");
useEffect(() => {
if (!uploadingId) return;
if (pollStatus === "READY" || pollStatus === "FAILED" || pollStatus === "TIMEOUT") {
setItems((prev) =>
prev.map((i) => (i.id === uploadingId ? { ...i, transcodingStatus: pollStatus } : i))
);
setUploadingId(null);
setUploadProgress(null);
}
}, [pollStatus, uploadingId]);
const closeModal = () => {
if (busy) return;
onClose();
};
// ── Создание ролика ──
const handleCreate = async () => {
if (creating) return;
let targetUrl = newTargetUrl.trim();
if (targetUrl) {
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
try {
// eslint-disable-next-line no-new
new URL(targetUrl);
} catch {
setCreateError(t("adminPanel.urlInvalid"));
return;
}
}
setCreating(true);
setCreateError("");
setActionError("");
setActionSuccess("");
try {
const created = await createFullscreenAd({ targetUrl: targetUrl || null });
setItems((prev) => [created, ...prev]);
setNewTargetUrl("");
setShowCreateForm(false);
setActionSuccess(t("adminPanel.fullscreenCreated"));
} catch (err) {
setCreateError(
err?.status === 400 ? t("adminPanel.adCreateFailed") : t("adminPanel.adServerError")
);
} finally {
setCreating(false);
}
};
// ── Загрузка видео (трёхшаговый флоу + поллинг статуса) ──
const handlePickVideo = (item) => {
if (busy) return;
pendingUploadIdRef.current = item.id;
if (fileInputRef.current) fileInputRef.current.value = "";
fileInputRef.current?.click();
};
const handleFileSelected = async (e) => {
const file = e.target.files?.[0] || null;
const itemId = pendingUploadIdRef.current;
pendingUploadIdRef.current = null;
if (!file || itemId == null) return;
if (!file.type.startsWith("video/")) {
setUploadStepError(t("admin.onlyVideo"));
return;
}
setUploadStepError("");
setActionError("");
setActionSuccess("");
setUploadProgress(0);
setUploadingId(itemId);
try {
// Шаг 2: presign
const { uploadUrl, sourceKey } = await presignFullscreenVideoUpload(itemId, file.type);
// Шаг 3: прямой PUT в MinIO без Authorization приложения
await putWithProgress(uploadUrl, file, (p) => setUploadProgress(p));
// Шаг 4: confirm — 202, запуск транскодинга на бэкенде
await confirmFullscreenVideoUpload(itemId, sourceKey);
// Дальше статус дозревает через поллинг (useTranscodingStatusPolling)
setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, transcodingStatus: "PROCESSING" } : i))
);
} catch (err) {
setUploadingId(null);
setUploadProgress(null);
setUploadStepError(
err?.name === "AdminApiError" ? t("adminPanel.adServerError") : t("adminPanel.uploadFailed")
);
}
};
// ── Активация / деактивация ──
const handleActivate = async (item) => {
if (actionBusyId) return;
setActionBusyId(item.id);
setActionError("");
setActionSuccess("");
try {
await activateFullscreenAd(item.id);
await reload();
setActionSuccess(t("adminPanel.fullscreenActivated"));
} catch (err) {
setActionError(
err?.status === 409 || err?.status === 422
? t("adminPanel.fullscreenActivateInvalid")
: t("adminPanel.fullscreenActionFailed")
);
} finally {
setActionBusyId(null);
}
};
const handleDeactivate = async (item) => {
if (actionBusyId) return;
setActionBusyId(item.id);
setActionError("");
setActionSuccess("");
try {
await deactivateFullscreenAd(item.id);
await reload();
setActionSuccess(t("adminPanel.fullscreenDeactivated"));
} catch (err) {
setActionError(t("adminPanel.fullscreenActionFailed"));
} finally {
setActionBusyId(null);
}
};
const activeCount = items.filter((i) => i.status === "ACTIVE").length;
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && closeModal()}>
<div className="f-modal">
<div className="f-modal-head">
<span className="f-modal-title">{t('admin.fullscreenTitle')}</span>
<button className="f-close-btn" type="button" onClick={closeModal} disabled={busy}>
<CloseIcon />
</button>
</div>
<div className="f-m-group">
<button
type="button"
className="f-m-submit"
disabled={busy || creating}
onClick={() => {
setShowCreateForm((v) => !v);
setCreateError("");
}}
>
+ {t('adminPanel.fullscreenCreate')}
</button>
</div>
{showCreateForm && (
<div className="f-m-group">
<label className="f-m-label" htmlFor="fs-ad-url">
{t('adminPanel.targetUrl')} <span className="f-hint" style={{ display: "inline" }}>— {t('adminPanel.fullscreenUrlOptional')}</span>
</label>
<input
id="fs-ad-url"
className="f-m-field"
type="text"
placeholder="https://example.com/landing"
value={newTargetUrl}
onChange={(e) => {
setNewTargetUrl(e.target.value);
setCreateError("");
}}
disabled={creating}
/>
<div className="f-hint">{t('adminPanel.fullscreenUrlHint')}</div>
{createError && <div className="f-err-msg">{createError}</div>}
<button
type="button"
className="f-m-submit"
disabled={creating}
style={{ marginTop: 8 }}
onClick={handleCreate}
>
<span className="f-m-submit-row">
{creating && <span className="f-m-spinner" />}
{t('adminPanel.fullscreenCreateSubmit')}
</span>
</button>
</div>
)}
<div className="admp-session-hint fs-ad-hint">
{t('adminPanel.fullscreenHint')}
</div>
{activeCount > 0 && (
<div className="fs-ad-hint">{t('adminPanel.fullscreenActivateHint')}</div>
)}
{actionSuccess && <div className="f-ok-msg">{actionSuccess}</div>}
{actionError && <div className="f-err-msg">{actionError}</div>}
{uploadStepError && <div className="f-err-msg">{uploadStepError}</div>}
{loadError && <div className="f-err-msg">{loadError}</div>}
<input
ref={fileInputRef}
className="f-img-input"
type="file"
accept={VIDEO_ACCEPT}
onChange={handleFileSelected}
disabled={busy}
/>
{loading ? (
<div className="f-center admp-empty">
<div className="f-spinner" />
</div>
) : items.length === 0 ? (
<div className="admp-no-ads">{t('adminPanel.fullscreenEmpty')}</div>
) : (
<div className="admp-ads fs-ad-list">
{items.map((item) => {
const isUploading = uploadingId === item.id;
const ready = item.transcodingStatus === "READY";
const processing = isProcessingTranscoding(item.transcodingStatus);
const isActive = item.status === "ACTIVE";
return (
<div className="admp-ad fs-ad-row" key={item.id}>
<span className={`admp-badge admp-badge--${String(item.status).toLowerCase()}`}>
{statusLabel(item.status, t)}
</span>
{item.transcodingStatus && (
<span className={`admp-badge ${processing ? "admp-badge--processing" : ""}${item.transcodingStatus === "READY" ? " admp-badge--ready" : ""}${item.transcodingStatus === "FAILED" ? " admp-badge--failed" : ""}${item.transcodingStatus === "TIMEOUT" ? " admp-badge--timeout" : ""}`}>
{transcodingLabel(item.transcodingStatus, t)}
</span>
)}
<div className="admp-ad-info">
{item.targetUrl ? (
<div className="admp-ad-url">{item.targetUrl}</div>
) : (
<div className="admp-ad-sub">{t('adminPanel.fullscreenNoUrl')}</div>
)}
{!isActive && ready && (
<div className="admp-ad-sub">{t('adminPanel.fullscreenCanActivate')}</div>
)}
</div>
<div className="admp-campaign-actions">
{isUploading ? (
<span className="admp-btn admp-btn--ghost" style={{ cursor: "default" }}>
{t('adminPanel.saving')}
</span>
) : item.status === "DRAFT" && !ready ? (
<button
type="button"
className="admp-btn admp-btn--ghost"
disabled={busy || actionBusyId !== null}
onClick={() => handlePickVideo(item)}
>
{t('adminPanel.fullscreenUploadVideo')}
</button>
) : ready && !isActive ? (
<button
type="button"
className="admp-btn"
disabled={busy || actionBusyId !== null}
onClick={() => handleActivate(item)}
>
{t('adminPanel.fullscreenMakeActive')}
</button>
) : isActive ? (
<button
type="button"
className="admp-btn admp-btn--ghost"
disabled={busy || actionBusyId !== null}
onClick={() => handleDeactivate(item)}
>
{t('adminPanel.fullscreenDeactivate')}
</button>
) : null}
</div>
{isUploading && uploadProgress !== null && (
<div className="f-m-group" style={{ flexBasis: "100%" }}>
<div className="f-upload-progress">
<div className="f-upload-progress-fill" style={{ width: `${uploadProgress}%` }} />
</div>
<div className="f-upload-progress-text">{uploadProgress}%</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
function statusLabel(status, t) {
if (status === "ACTIVE") return t("adminPanel.fullscreenStatusActive");
if (status === "INACTIVE") return t("adminPanel.fullscreenStatusInactive");
return t("adminPanel.fullscreenStatusDraft");
}
function transcodingLabel(status, t) {
if (isProcessingTranscoding(status)) return t("adminPanel.processing");
if (status === "READY") return t("adminPanel.ready");
if (status === "TIMEOUT") return t("adminPanel.processingTimeout");
if (status === "FAILED") return t("adminPanel.failed");
return status || "—";
}

View File

@@ -0,0 +1,361 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import CloseIcon from "../icons/CloseIcon";
import {
createAdvertising,
presignVideoUpload,
confirmVideoUpload,
uploadAdImage,
} from "../../api/adminAdsApi";
import { putWithProgress } from "../../api/adsApi";
import { MAX_SIZE_MB } from "../../hooks/useImageUpload";
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp";
/**
* Создание объявления (креатива) в кампании.
*
* IMAGE: файл уходит на POST /api/v1/admin/ads/image сразу при выборе
* (валидация размера/типа — клиентская, ошибки до отправки).
* Полученный mediaKey хранится в состоянии и подставляется в
* POST /api/v1/admin/ads при сабмите. Пока загрузка не завершена
* (или упала), кнопка "Добавить объявление" заблокирована.
* VIDEO: трёхшаговый флоу create → presign → PUT на MinIO → confirm,
* после чего модалка закрывается, а статус транскодинга поллится
* на странице списка кампаний (см. AdminAdsPage).
*/
export default function AdvertisingCreateModal({ campaignId, onClose, onCreated }) {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const imageInputRef = useRef(null);
const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(null); // 0..100 во время PUT в MinIO
const [stepError, setStepError] = useState(""); // ошибка конкретного шага
const [submitError, setSubmitError] = useState(""); // общая ошибка
// IMAGE-загрузка: файл, превью (object URL) и mediaKey, полученный с бэкенда.
const [mediaKey, setMediaKey] = useState("");
const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [imageError, setImageError] = useState("");
const [uploadingImage, setUploadingImage] = useState(false);
// Освобождаем object URL превью при замене файла и размонтировании.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
};
}, [imagePreview]);
const schema = useMemo(
() => z.object({
type: z.enum(["IMAGE", "VIDEO"]),
targetUrl: z.string().trim().url(t("adminPanel.urlInvalid")),
weight: z.coerce.number().int(t("adminPanel.weightInvalid")).positive(t("adminPanel.weightInvalid")),
}),
[t]
);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
mode: "onSubmit",
defaultValues: { type: "IMAGE", targetUrl: "", weight: "" },
});
const type = watch("type");
const handleFileChange = (e) => {
const selected = e.target.files?.[0] || null;
setFile(selected);
setStepError("");
setSubmitError("");
};
/**
* IMAGE: валидацию (тип, размер ≤ 10 МБ) делаем на клиенте и показываем
* ошибку сразу; затем сразу POST /api/v1/admin/ads/image и сохраняем mediaKey.
* Превью — локальный object URL выбранного файла (до ответа сервера).
*/
const handleImageChange = async (e) => {
const selected = e.target.files?.[0] || null;
// Сбрасываем input заранее, чтобы повторно можно было выбрать тот же файл
if (imageInputRef.current) imageInputRef.current.value = "";
setImageError("");
setStepError("");
setSubmitError("");
if (!selected) return;
if (!selected.type.startsWith("image/")) {
setImageError(t('imageUpload.invalidType'));
return;
}
if (selected.size > MAX_SIZE_MB * 1024 * 1024) {
setImageError(t('imageUpload.tooLarge', { maxSize: MAX_SIZE_MB }));
return;
}
setImageFile(selected);
setImagePreview(URL.createObjectURL(selected));
setMediaKey("");
setUploadingImage(true);
try {
const { mediaKey: key } = await uploadAdImage(selected);
setMediaKey(key);
} catch {
setImageError(t('adminPanel.uploadFailed'));
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = () => {
setImageFile(null);
setImagePreview(null);
setMediaKey("");
setImageError("");
};
/** Шаги 2–4 видео-флоу: пресайн → PUT на MinIO → confirm. */
const uploadVideoSteps = async (advertisingId, selectedFile, onNext) => {
// Шаг 2: presign
onNext("presign");
const { uploadUrl, sourceKey } = await presignVideoUpload(advertisingId, selectedFile.type);
// Шаг 3: прямой PUT сторонним доменом MinIO без Authorization приложения
onNext("upload");
await putWithProgress(uploadUrl, selectedFile, (p) => setProgress(p));
// Шаг 4: confirm — 202, запуск транскодинга
onNext("confirm");
await confirmVideoUpload(advertisingId, sourceKey);
};
const onSubmit = async (values) => {
if (submitting) return;
setSubmitting(true);
setSubmitError("");
setStepError("");
const { type: adType, targetUrl, weight } = values;
// Для IMAGE обязателен уже полученный mediaKey (кнопка заблокирована,
// пока загрузка не завершилась/не упала) — защита от сабмита через Enter.
if (adType === "IMAGE" && !mediaKey) {
setStepError(t("adminPanel.mediaKeyRequired"));
setSubmitting(false);
return;
}
// Для VIDEO файл обязателен: проверяем ДО создания объявления на бэкенде,
// чтобы не оставить осиротевшее объявление (созданное, но без медиа)
// в статусе PENDING. Форма при этом не сбрасывается.
if (adType === "VIDEO" && !file) {
setStepError(t("adminPanel.videoRequired"));
setSubmitting(false);
return;
}
try {
// Шаг 1 (общий): создание объявления
const ad = await createAdvertising({
campaignId,
type: adType,
mediaKey: adType === "IMAGE" ? mediaKey : null,
targetUrl,
weight: Number(weight),
});
if (adType === "IMAGE") {
// IMAGE → transcodingStatus сразу "READY", загрузки нет.
onCreated({ ...ad, transcodingStatus: ad.transcodingStatus || "READY" });
return;
}
await uploadVideoSteps(
ad.id,
file,
(stage) => {
if (stage === "upload") setProgress(0);
setSubmitError("");
setStepError("");
}
);
// Модалку закрываем сразу после confirm; транскодинг поллится страницей.
onCreated({ ...ad, transcodingStatus: "PENDING" });
} catch (err) {
setSubmitting(false);
setProgress(null);
if (err?.status === 400) {
setSubmitError(t("adminPanel.adCreateFailed"));
return;
}
// Контекст не теряем: по шагу показываем понятную ошибку
setStepError(
err?.name === "AdminApiError"
? t("adminPanel.adServerError")
: t("adminPanel.uploadFailed")
);
}
};
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adCreateTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-type">{t('adminPanel.adType')}</label>
<select
id="admp-ad-type"
className={`f-m-field${errors.type ? " err" : ""}`}
disabled={submitting}
{...register("type")}
>
<option value="IMAGE">IMAGE</option>
<option value="VIDEO">VIDEO</option>
</select>
{errors.type && <div className="f-err-msg">{errors.type.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-url">{t('adminPanel.targetUrl')}</label>
<input
id="admp-ad-url"
className={`f-m-field${errors.targetUrl ? " err" : ""}`}
type="text"
placeholder="https://example.com/landing"
disabled={submitting}
{...register("targetUrl")}
/>
{errors.targetUrl && <div className="f-err-msg">{errors.targetUrl.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-weight">{t('adminPanel.weight')}</label>
<input
id="admp-ad-weight"
className={`f-m-field${errors.weight ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="10"
disabled={submitting}
{...register("weight")}
/>
{errors.weight ? (
<div className="f-err-msg">{errors.weight.message}</div>
) : (
<div className="f-hint">{t('adminPanel.weightHint')}</div>
)}
</div>
{type === "IMAGE" ? (
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-image">{t('adminPanel.adImage')}</label>
{imagePreview ? (
<>
<div className="f-img-preview-wrap">
<img src={imagePreview} alt={imageFile?.name || ""} className="f-img-preview" />
<button
className="f-img-remove"
type="button"
onClick={handleRemoveImage}
disabled={uploadingImage || submitting}
aria-label={t('adminPanel.adImageRemove')}
>
<CloseIcon />
</button>
</div>
{imageFile && (
<div className="f-hint" style={{ marginTop: 6 }}>
{uploadingImage ? t('adminPanel.adImageUploading') : imageFile.name}
</div>
)}
</>
) : (
<label className="f-img-upload-label">
<input
ref={imageInputRef}
id="admp-ad-image"
type="file"
accept={IMAGE_ACCEPT}
onChange={handleImageChange}
className="f-img-input"
disabled={uploadingImage || submitting}
/>
<span>{t('adminPanel.adImage')} 📷</span>
</label>
)}
{imageError ? (
<div className="f-err-msg">{imageError}</div>
) : (
<div className="f-hint">{t('adminPanel.adImageHint', { maxSize: MAX_SIZE_MB })}</div>
)}
</div>
) : (
<div className="f-m-group">
<input
ref={fileInputRef}
id="admp-ad-video"
className="f-img-input"
type="file"
accept={VIDEO_ACCEPT}
onChange={handleFileChange}
disabled={submitting}
/>
<label className="f-img-upload-label" htmlFor="admp-ad-video">
📹 {file ? file.name : t('adminPanel.adVideo')}
</label>
<div className="f-hint">{t('adminPanel.adVideoHint')}</div>
</div>
)}
{progress !== null && submitting && (
<div className="f-m-group">
<div className="f-upload-progress">
<div className="f-upload-progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="f-upload-progress-text">{progress}%</div>
</div>
)}
{stepError && <div className="f-err-msg" style={{ marginTop: 4 }}>{stepError}</div>}
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button
className="f-m-submit"
type="submit"
disabled={submitting || (type === "IMAGE" && !mediaKey)}
>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.savingAd') : t('adminPanel.adCreateSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import CloseIcon from "../icons/CloseIcon";
import { updateAdvertising } from "../../api/adminAdsApi";
/**
* Редактирование объявления: только targetUrl и weight.
* Тип, медиа и статус транскодинга фиксируются при создании —
* смена креатива делается через новое объявление.
*/
export default function AdvertisingEditModal({ ad, onClose, onUpdated }) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const schema = useMemo(
() => z.object({
targetUrl: z.string().trim().url(t("adminPanel.urlInvalid")),
weight: z.coerce.number().int(t("adminPanel.weightInvalid")).positive(t("adminPanel.weightInvalid")),
}),
[t]
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
mode: "onSubmit",
defaultValues: {
targetUrl: ad.targetUrl || "",
weight: ad.weight ?? "",
},
});
const onSubmit = async (values) => {
if (submitting) return;
setSubmitting(true);
setSubmitError("");
try {
const updated = await updateAdvertising(ad.id, {
targetUrl: values.targetUrl,
weight: Number(values.weight),
});
onUpdated({ ...ad, ...updated });
} catch (err) {
setSubmitError(
err?.status === 400 ? t("adminPanel.adEditInvalid") : t("adminPanel.adEditFailed")
);
setSubmitting(false);
}
};
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adEditTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-edit-url">{t('adminPanel.targetUrl')}</label>
<input
id="admp-ad-edit-url"
className={`f-m-field${errors.targetUrl ? " err" : ""}`}
type="text"
placeholder="https://example.com/landing"
disabled={submitting}
{...register("targetUrl")}
/>
{errors.targetUrl && <div className="f-err-msg">{errors.targetUrl.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-ad-edit-weight">{t('adminPanel.weight')}</label>
<input
id="admp-ad-edit-weight"
className={`f-m-field${errors.weight ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="10"
disabled={submitting}
{...register("weight")}
/>
{errors.weight ? (
<div className="f-err-msg">{errors.weight.message}</div>
) : (
<div className="f-hint">{t('adminPanel.weightHint')}</div>
)}
</div>
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.savingAd') : t('adminPanel.adEditSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import CloseIcon from "../icons/CloseIcon";
import { createCampaign } from "../../api/adminAdsApi";
import CampaignFormFields, { createCampaignSchema, toLocalDateTime } from "./CampaignFormFields";
export default function CampaignCreateModal({ onClose, onCreated }) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const schema = useMemo(() => createCampaignSchema(t), [t]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({ resolver: zodResolver(schema), mode: "onSubmit" });
const onSubmit = async (values) => {
if (submitting) return;
setSubmitting(true);
setSubmitError("");
try {
const campaign = await createCampaign({
name: values.name,
startDate: toLocalDateTime(values.startDate),
endDate: toLocalDateTime(values.endDate),
dailyImpressionCap: Number(values.dailyImpressionCap),
});
onCreated(campaign);
} catch (err) {
// 400 — по контракту «Проверьте даты и лимит показов»
setSubmitError(
err?.status === 400 ? t("adminPanel.campDateCapError") : t("adminPanel.campCreateFailed")
);
} finally {
setSubmitting(false);
}
};
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.campaignCreateTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<CampaignFormFields register={register} errors={errors} submitting={submitting} t={t} />
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.campaignCreateSubmit')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import CloseIcon from "../icons/CloseIcon";
import { updateCampaign } from "../../api/adminAdsApi";
import CampaignFormFields, { createCampaignSchema, toDatetimeLocalValue, toLocalDateTime } from "./CampaignFormFields";
/**
* Редактирование полей кампании (название, даты, лимит показов).
* Статус намеренно не включён — он меняется отдельной модалкой
* (CampaignStatusUpdateModal) и отдельным PATCH-запросом.
*/
export default function CampaignEditModal({ campaign, onClose, onUpdated }) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const schema = useMemo(() => createCampaignSchema(t), [t]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
mode: "onSubmit",
defaultValues: {
name: campaign.name || "",
startDate: toDatetimeLocalValue(campaign.startDate),
endDate: toDatetimeLocalValue(campaign.endDate),
dailyImpressionCap: campaign.dailyImpressionCap ?? "",
},
});
const onSubmit = async (values) => {
if (submitting) return;
setSubmitting(true);
setSubmitError("");
try {
const updated = await updateCampaign(campaign.id, {
name: values.name,
startDate: toLocalDateTime(values.startDate),
endDate: toLocalDateTime(values.endDate),
dailyImpressionCap: Number(values.dailyImpressionCap),
});
onUpdated({ ...campaign, ...updated });
} catch (err) {
// 400 — по контракту «Проверьте даты и лимит показов»
setSubmitError(
err?.status === 400 ? t("adminPanel.campDateCapError") : t("adminPanel.campUpdateFailed")
);
} finally {
setSubmitting(false);
}
};
return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && !submitting && onClose()}>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.campaignEditTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<CampaignFormFields register={register} errors={errors} submitting={submitting} t={t} />
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.saveCampaign')}
</span>
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { z } from "zod";
/**
* Общие поля формы кампании (name / startDate / endDate / dailyImpressionCap),
* переиспользуются в CampaignCreateModal и CampaignEditModal.
*
* Статус намеренно НЕ входит — он редактируется отдельным действием.
*/
/** "2026-09-20T14:30" → "2026-09-20T14:30:00" (LocalDateTime без таймзоны). */
export function toLocalDateTime(value) {
const trimmed = (value || "").trim();
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(trimmed)) return `${trimmed}:00`;
return trimmed;
}
/** "2026-09-20T14:30:00" → "2026-09-20T14:30" (для value input.datetime-local). */
export function toDatetimeLocalValue(value) {
const trimmed = (value || "").trim();
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/.exec(trimmed);
return match ? match[1] : trimmed;
}
export function createCampaignSchema(t) {
return z
.object({
name: z.string().trim().min(1, t("adminPanel.fieldRequired")).max(255, t("adminPanel.nameTooLong")),
startDate: z.string().min(1, t("adminPanel.fieldRequired")),
endDate: z.string().min(1, t("adminPanel.fieldRequired")),
dailyImpressionCap: z.coerce
.number()
.int(t("adminPanel.capInvalid"))
.positive(t("adminPanel.capInvalid")),
})
.superRefine((data, ctx) => {
if (!data.startDate || !data.endDate) return;
if (
new Date(toLocalDateTime(data.endDate)).getTime() <=
new Date(toLocalDateTime(data.startDate)).getTime()
) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: t("adminPanel.endAfterStart"),
});
}
});
}
export default function CampaignFormFields({ register, errors, submitting, t }) {
return (
<>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-name">{t('adminPanel.campaignName')}</label>
<input
id="admp-camp-name"
className={`f-m-field${errors.name ? " err" : ""}`}
type="text"
maxLength={255}
placeholder="Летняя распродажа"
disabled={submitting}
{...register("name")}
/>
{errors.name && <div className="f-err-msg">{errors.name.message}</div>}
</div>
<div className="f-m-row">
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-start">{t('adminPanel.campaignStart')}</label>
<input
id="admp-camp-start"
className={`f-m-field${errors.startDate ? " err" : ""}`}
type="datetime-local"
disabled={submitting}
{...register("startDate")}
/>
{errors.startDate && <div className="f-err-msg">{errors.startDate.message}</div>}
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-end">{t('adminPanel.campaignEnd')}</label>
<input
id="admp-camp-end"
className={`f-m-field${errors.endDate ? " err" : ""}`}
type="datetime-local"
disabled={submitting}
{...register("endDate")}
/>
{errors.endDate && <div className="f-err-msg">{errors.endDate.message}</div>}
</div>
</div>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-camp-cap">{t('adminPanel.campaignCap')}</label>
<input
id="admp-camp-cap"
className={`f-m-field${errors.dailyImpressionCap ? " err" : ""}`}
type="number"
min={1}
step={1}
placeholder="1000"
disabled={submitting}
{...register("dailyImpressionCap")}
/>
{errors.dailyImpressionCap ? (
<div className="f-err-msg">{errors.dailyImpressionCap.message}</div>
) : (
<div className="f-hint">{t('adminPanel.campaignCapHint')}</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "../icons/CloseIcon";
import { updateCampaignStatus } from "../../api/adminAdsApi";
import { getNextCampaignStatuses } from "../../utils/campaignStatus";
export default function CampaignStatusUpdateModal({ campaignId, currentStatus, onClose, onUpdated }) {
const { t } = useTranslation();
const allowed = getNextCampaignStatuses(currentStatus);
const [nextStatus, setNextStatus] = useState(allowed[0] || "");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const hasTransitions = allowed.length > 0;
const handleSubmit = async (e) => {
e.preventDefault();
if (!hasTransitions || submitting || !nextStatus) return;
setSubmitting(true);
setSubmitError("");
try {
const updated = await updateCampaignStatus(campaignId, nextStatus);
onUpdated(campaignId, updated.status || nextStatus);
} catch (err) {
// 422 — по контракту «Такой переход статуса недопустим»
setSubmitError(
err?.status === 422 ? t("adminPanel.statusTransitionError") : t("adminPanel.statusChangeFailed")
);
} finally {
setSubmitting(false);
}
};
return (
<div
className="f-modal-bg"
onClick={e => e.target === e.currentTarget && !submitting && onClose()}
>
<div className="f-modal f-modal-sm">
<div className="f-modal-head">
<span className="f-modal-title">{t('adminPanel.adStatusTitle')}</span>
<button className="f-close-btn" type="button" onClick={onClose} disabled={submitting}>
<CloseIcon />
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="f-m-group">
<span className="f-m-label">{t('adminPanel.currentStatus')}</span>
<p className="admp-current-status">
<span className={`admp-badge admp-badge--${String(currentStatus).toLowerCase()}`}>
{currentStatus}
</span>
</p>
</div>
{hasTransitions ? (
<>
<div className="f-m-group">
<label className="f-m-label" htmlFor="admp-status-next">{t('adminPanel.newStatus')}</label>
<select
id="admp-status-next"
className="f-m-field"
value={nextStatus}
onChange={(e) => setNextStatus(e.target.value)}
disabled={submitting}
>
{allowed.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{submitError && <div className="f-err-msg" style={{ marginTop: 4 }}>{submitError}</div>}
<button className="f-m-submit" type="submit" disabled={submitting}>
<span className="f-m-submit-row">
{submitting && <span className="f-m-spinner" />}
{submitting ? t('adminPanel.saving') : t('adminPanel.apply')}
</span>
</button>
</>
) : (
<p className="admp-no-transitions">{t('adminPanel.noTransitions')}</p>
)}
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,9 @@
import { useMemo } from "react";
import PostOnFeed from "../../pages/PostOnFeed";
import 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}
/>
)
)}
</>
);
}

View File

@@ -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" />