@@ -483,6 +502,7 @@ export default function MainFeed() {
onEdit={handleEdit}
onDelete={actions.handleDelete}
onMarkFound={actions.handleMarkFound}
+ onReopen={actions.handleReopen}
onImageClick={handleImageClick}
onLocationClick={handleLocationClick}
onAuthRequired={() => modals.open("auth")}
@@ -502,6 +522,8 @@ export default function MainFeed() {
onMenuClick={() => setSidebarOpen(v => !v)}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
+ statusFilter={statusFilter}
+ onStatusFilterChange={setStatusFilter}
topBarRef={topBarRef}
/>
)}
diff --git a/src/pages/PostOnFeed.jsx b/src/pages/PostOnFeed.jsx
index 842c18f..a8a1136 100644
--- a/src/pages/PostOnFeed.jsx
+++ b/src/pages/PostOnFeed.jsx
@@ -3,16 +3,19 @@ import PinIcon from "../components/icons/PinIcon";
import CalendarIcon from "../components/icons/CalendarIcon";
import PhoneIcon from "../components/icons/PhoneIcon";
import TrashIcon from "../components/icons/TrashIcon";
+import CheckIcon from "../components/icons/CheckIcon";
import {useContactSeller} from "../hooks/useContactSeller";
import {useAuthContext} from "../context/AuthContext";
import {getImageUrl} from "../config/authConstants";
+import {CATEGORY_PET} from "../config/feedConstants";
+import {getFoundAt, formatFoundDate} from "../utils/foundAt";
import DateCalendarPopup from "../components/DateCalendarPopup";
import RewardPopup from "../components/feed/RewardPopup";
import Toast from "../components/Toast";
import {useTranslation} from 'react-i18next';
export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen, isOwner, authorLabel, allowEditClosed, onImageClick, onLocationClick, onAuthRequired, compact }) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const [showCalendar, setShowCalendar] = useState(false);
const [showReward, setShowReward] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
@@ -50,6 +53,15 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
: null;
const isClosed = ad.status === 'CLOSED';
+ // Для питомцев «найдено» — это хорошая новость с воссоединением, поэтому
+ // формулировка эмоциональнее, чем сухое «Найдено».
+ const isPet = ad.category === CATEGORY_PET;
+ const foundText = isPet ? t('postCard.petFoundStatus') : t('postCard.foundStatus');
+ const foundDate = isClosed ? formatFoundDate(getFoundAt(ad), i18n.language) : null;
+ // aria-подпись собираем целиком: статус не должен читаться только цветом.
+ const foundAriaText = foundDate
+ ? t('postCard.foundStatusWithDate', {status: foundText, date: foundDate})
+ : foundText;
const headAccentClass = ad.type === "found" ? " f-head-found" : " f-head-loss";
@@ -60,7 +72,9 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
);
return (
-
+
@@ -75,9 +89,6 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
{typeBadge}
- {isClosed && (
- {t('postCard.foundStatus')}
- )}
@@ -86,6 +97,14 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
?

:
{ad.emoji || "📋"}
}
+ {/* Плашка статуса поверх фото: при быстром скролле шапка карточки
+ проезжает мимо, а фото с плашкой — нет. */}
+ {isClosed && (
+
+
+ {foundText}
+
+ )}
{!compact && (
@@ -210,15 +229,28 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
)}
- {onReopen && isOwner && isClosed && (
+ {isClosed && (
-
)}
diff --git a/src/styles/card.css b/src/styles/card.css
index 1c178b4..96456e9 100644
--- a/src/styles/card.css
+++ b/src/styles/card.css
@@ -232,16 +232,6 @@ button.f-card-reward-btn:focus-visible {
.f-type-loss { background: rgba(237,73,86,.1); color: #ed4956; border: 1px solid rgba(237,73,86,.25); }
.f-type-found { background: rgba(48,168,94,.1); color: #30a85e; border: 1px solid rgba(48,168,94,.25); }
-/* Status badge for closed/found posts */
-.f-status-badge {
- border-radius: 999px; padding: 2px 8px;
- font-size: .7rem; font-weight: 800;
- display: inline-block; vertical-align: middle;
-}
-.f-status-closed {
- background: rgba(48,168,94,.12); color: #30a85e; border: 1px solid rgba(48,168,94,.3);
-}
-
/* ── Role badges (LOST_AND_FOUND / METRO) ── */
.f-role-badge {
display: inline-flex; align-items: center; gap: 4px;
@@ -264,6 +254,26 @@ button.f-card-reward-btn:focus-visible {
font-size: 9px; font-weight: 900; line-height: 1;
}
+/* ── Плашка «Найдено» поверх фото ──
+ Живёт на самом изображении, а не в шапке карточки: при быстром скролле
+ шапка проезжает мимо, фото с плашкой остаётся в кадре. */
+.f-img-found-badge {
+ position: absolute; top: 12px; left: 12px; z-index: 3;
+ display: inline-flex; align-items: center; gap: 5px;
+ padding: 5px 12px 5px 10px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #35c46f, #1f9d55);
+ border: 1px solid rgba(255,255,255,.35);
+ color: #fff;
+ font-size: .76rem; font-weight: 800; line-height: 1.2;
+ white-space: nowrap;
+ box-shadow: 0 2px 10px rgba(0,0,0,.3);
+ /* Не перехватываем клик по фото (открытие карусели). */
+ pointer-events: none;
+}
+.f-img-found-badge svg { flex-shrink: 0; }
+.f-img-found-badge-pet { background: linear-gradient(135deg, #ffb03a, #ff7a45); }
+
/* ── Footer (phone + owner actions) ── */
.f-card-foot {
display: flex; flex-direction: row; align-items: center; justify-content: space-between;
@@ -291,14 +301,29 @@ button.f-card-reward-btn:focus-visible {
border-color: rgba(var(--teal-rgb), .5);
}
-/* Closed/Found card styling */
+/* Closed/Found card styling.
+ Приглушение намеренно лёгкое: объявление найдено — это хорошая новость,
+ поэтому НЕ гасим карточку целиком (иначе она читается как удалённая).
+ Работают только фото (grayscale/brightness), текст остаётся читаемым. */
.f-card-closed {
- opacity: .8;
border-color: rgba(48,168,94,.3);
}
-.f-card-closed:hover { border-color: rgba(48,168,94,.5); }
-.f-card-closed .f-card-img { filter: grayscale(.5); }
-.f-card-closed .f-card-caption-name { text-decoration: line-through; opacity: .7; }
+/* Зелёное кольцо вместо красного/зелёного типа: статус «найдено» здесь
+ важнее того, потеря это была или находка (иначе кольцо спорит с плашкой). */
+.f-card-closed.f-card-loss,
+.f-card-closed.f-card-found {
+ box-shadow: 0 0 0 2px rgba(48,168,94,.45);
+}
+.f-card-closed:hover,
+.f-card-closed:hover.f-card-loss,
+.f-card-closed:hover.f-card-found {
+ border-color: rgba(48,168,94,.5);
+ box-shadow: 0 0 0 2px rgba(48,168,94,.65);
+}
+.f-card-closed .f-card-img img,
+.f-card-closed .f-card-emoji {
+ filter: grayscale(.6) brightness(.85);
+}
/* Edit & Delete buttons */
.f-card-edit-btn,
@@ -332,13 +357,28 @@ button.f-card-reward-btn:focus-visible {
.f-card-found-btn:hover {
background: rgba(48,168,94,.18); border-color: rgba(48,168,94,.5);
}
-/* Возврат закрытого объявления в ленту (только в админ-панели). */
-.f-card-reopen-btn {
- background: rgba(var(--teal-rgb),.1); border-color: rgba(var(--teal-rgb),.3);
- color: var(--teal);
+/* «Найдено ✓ <дата>» — подтверждение уже применённого действия: неактивное,
+ но всё ещё читаемое, без серости «заблокированной» кнопки. */
+.f-card-found-btn-done {
+ background: rgba(48,168,94,.14); border-color: rgba(48,168,94,.35);
+ color: #30a85e; opacity: 1; cursor: default;
}
-.f-card-reopen-btn:hover {
- background: rgba(var(--teal-rgb),.18); border-color: rgba(var(--teal-rgb),.5);
+.f-card-found-btn-done:hover {
+ background: rgba(48,168,94,.14); border-color: rgba(48,168,94,.35);
+}
+/* Отмена случайного нажатия «Вернулось к владельцу» — только автору. */
+.f-card-found-undo {
+ margin-left: 10px;
+ background: none; border: none; padding: 2px;
+ color: var(--muted); font-family: var(--font);
+ font-size: .78rem; text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: pointer; transition: color .2s;
+}
+.f-card-found-undo:hover { color: var(--teal); }
+.f-card-found-undo:focus-visible {
+ outline: 2px solid rgba(var(--teal-rgb), .6); outline-offset: 2px;
+ border-radius: 4px;
}
/* ── Calendar Popup ── */
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 31d5172..0b7f4b7 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -282,7 +282,7 @@
.f-topbar-dropdown-menu {
position: absolute; top: 100%; left: 0; right: 0;
z-index: 50;
- display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px;
+ display: flex; flex-flow: row wrap; align-items: center; justify-content: center; gap: 6px;
padding: 8px 12px;
margin-top: 6px;
background: rgba(0, 0, 0, .72);
@@ -292,6 +292,14 @@
box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
}
+/* Разделитель между группами фильтров (тип / состояние). */
+.f-topbar-dropdown-sep {
+ width: 1px; align-self: stretch; margin: 2px 3px;
+ background: rgba(255, 255, 255, .16);
+ border-radius: 1px;
+}
+:root:not([data-theme="dark"]) .f-topbar-dropdown-sep { background: rgba(0, 0, 0, .12); }
+
:root:not([data-theme="dark"]) .f-topbar-dropdown-menu {
background: rgba(255, 255, 255, .92);
border-color: rgba(0, 0, 0, .08);
diff --git a/src/styles/modal.css b/src/styles/modal.css
index b94dc05..16ec0f2 100644
--- a/src/styles/modal.css
+++ b/src/styles/modal.css
@@ -214,6 +214,25 @@ select.f-m-field::selection { background: transparent; }
box-shadow: 0 8px 40px rgba(0,0,0,.5);
touch-action: pan-y;
}
+/* Та же лёгкая приглушка, что и в карточке: объявление найдено, но всё равно
+ хорошо видно, что это за вещь/питомец. */
+.f-viewer-img-closed { filter: grayscale(.6) brightness(.85); }
+
+/* Плашка «Найдено» поверх фото в просмотрщике: остаётся на каждом слайде
+ карусели, поэтому статус не теряется при листании. */
+.f-viewer-found-badge {
+ position: absolute; top: 10px; left: 10px; z-index: 2;
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 14px 7px 11px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #35c46f, #1f9d55);
+ border: 1px solid rgba(255,255,255,.35);
+ color: #fff;
+ font-size: .85rem; font-weight: 800; line-height: 1.2;
+ white-space: nowrap;
+ box-shadow: 0 2px 12px rgba(0,0,0,.35);
+ pointer-events: none;
+}
.f-viewer-contact {
margin-top: 16px;
padding: 10px 28px;
diff --git a/src/utils/foundAt.js b/src/utils/foundAt.js
new file mode 100644
index 0000000..9d5075a
--- /dev/null
+++ b/src/utils/foundAt.js
@@ -0,0 +1,81 @@
+// Дата, когда объявление было отмечено как «найдено».
+//
+// Бэкенд статус отдаёт, но момент перехода в CLOSED в ответе /api/v1/posts
+// сейчас не приходит, а в карточке он нужен («Найдено 3 октября»). Поэтому:
+// 1) если поле closedAt/closedDate появится в DTO — берём его (приоритет);
+// 2) иначе — дата, которую клиент запомнил в момент нажатия «Вернулось к
+// владельцу» (rememberFoundAt), т.е. точная для текущего устройства;
+// 3) иначе — null: показываем просто «Найдено», без выдуманной даты.
+
+const STORAGE_KEY = "postFoundAt";
+// Хранилище не должно расти бесконечно: держим последние отметки.
+const MAX_ENTRIES = 100;
+
+function readStore() {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : null;
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ // Приватный режим / отключённое хранилище — работаем без даты.
+ return {};
+ }
+}
+
+function writeStore(store) {
+ try {
+ const entries = Object.entries(store).slice(-MAX_ENTRIES);
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
+ } catch {
+ // Не смогли записать — дата просто не запомнится, это не критично.
+ }
+}
+
+export function rememberFoundAt(postId, iso = new Date().toISOString()) {
+ if (postId === undefined || postId === null) return;
+ const store = readStore();
+ store[postId] = iso;
+ writeStore(store);
+}
+
+export function forgetFoundAt(postId) {
+ const store = readStore();
+ if (!(postId in store)) return;
+ delete store[postId];
+ writeStore(store);
+}
+
+export function getFoundAt(ad) {
+ if (!ad) return null;
+ return ad.closedAt || ad.closedDate || readStore()[ad.id] || null;
+}
+
+/**
+ * Intl.DateTimeFormat дорого создавать, а дата рендерится в каждой закрытой
+ * карточке ленты — держим по одному форматтеру на локаль.
+ */
+const formatterCache = new Map();
+
+function getFormatter(locale) {
+ const cached = formatterCache.get(locale);
+ if (cached) return cached;
+ const formatter = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long" });
+ formatterCache.set(locale, formatter);
+ return formatter;
+}
+
+/**
+ * «3 октября» / «October 3» — коротко и по-человечески, без года.
+ * Возвращает null на невалидную дату, чтобы не показывать «Invalid Date».
+ */
+export function formatFoundDate(value, language) {
+ if (!value) return null;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+ const locale = language === "en" ? "en-US" : "ru-RU";
+ try {
+ return getFormatter(locale).format(date);
+ } catch {
+ return date.toLocaleDateString(locale);
+ }
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ed63278
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
\ No newline at end of file