Compare commits

5 Commits
master ... main

Author SHA1 Message Date
SlimusMinus
b97387b159 Fix home name
All checks were successful
deploy-frontend / deploy (push) Successful in 11s
2026-10-08 00:27:51 +03:00
SlimusMinus
9d38a77904 Test auto deploy
All checks were successful
deploy-frontend / deploy (push) Successful in 45s
2026-10-07 23:37:02 +03:00
SlimusMinus
1d3c5bcf0f Add Docker build and deploy pipeline
All checks were successful
deploy-frontend / deploy (push) Successful in 1m58s
2026-10-07 23:31:51 +03:00
SlimusMinus
bfbe84c9aa Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 5s
2026-10-07 23:29:38 +03:00
SlimusMinus
6dc61050d9 Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 11s
2026-10-07 23:27:38 +03:00
23 changed files with 722 additions and 41 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
node_modules
build
.idea

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Скопируйте этот файл в .env и подставьте свои значения.
# .env в .gitignore не попадает — реальные ключи не должны попадать в репозиторий.
# URL бэкенда. В dev чаще всего http://localhost:8080
REACT_APP_API_URL=http://localhost:8080
# Ключ Яндекс.Геокодера (обратное геокодирование адреса в координаты).
# Получить: https://developer.tech.yandex.ru/
REACT_APP_YANDEX_GEOCODER_KEY=
# Публичный ключ Яндекс.Карт (подставляется в public/index.html при загрузке API-карт).
# Получить: https://developer.tech.yandex.ru/
REACT_APP_YANDEX_MAPS_KEY=
# Почта поддержки в модалке «Поддержка». Необязательно — есть значение по умолчанию.
REACT_APP_SUPPORT_EMAIL=

View File

@@ -0,0 +1,24 @@
name: deploy-frontend
on:
push:
branches: [main]
jobs:
deploy:
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Build image
env:
GEOCODER_KEY: ${{ secrets.REACT_APP_YANDEX_GEOCODER_KEY }}
MAPS_KEY: ${{ secrets.REACT_APP_YANDEX_MAPS_KEY }}
run: |
docker build \
--build-arg REACT_APP_YANDEX_GEOCODER_KEY="$GEOCODER_KEY" \
--build-arg REACT_APP_YANDEX_MAPS_KEY="$MAPS_KEY" \
-t nakhodka-frontend:latest \
-t nakhodka-frontend:${{ github.sha }} .
- name: Restart frontend
run: docker compose -f /opt/nakhodka/docker-compose.yml up -d frontend
- name: Cleanup
run: docker image prune -f

9
.npmrc Normal file
View File

@@ -0,0 +1,9 @@
# react-scripts@5.0.1 объявляет typescript как ОПЦИОНАЛЬНЫЙ peer с диапазоном
# "^3.2.1 || ^4", а проекту нужен TypeScript 5.x: на TS 4 не собираются
# @types/react 18.3 и zod v4 (269 ошибок в node_modules).
#
# Конфликт чисто формальный: react-scripts типы не проверяет, их вырезает babel,
# а tsc запускается отдельно через `npm run typecheck`. Поэтому разрешаем
# npm игнорировать peer-диапазоны и не заставлять каждого нового разработчика
# ставить зависимости через --force / --legacy-peer-deps вручную.
legacy-peer-deps=true

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
ARG REACT_APP_API_URL=https://app.nahodka-test.online
ARG REACT_APP_YANDEX_GEOCODER_KEY=
ARG REACT_APP_YANDEX_MAPS_KEY=
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_YANDEX_GEOCODER_KEY=$REACT_APP_YANDEX_GEOCODER_KEY
ENV REACT_APP_YANDEX_MAPS_KEY=$REACT_APP_YANDEX_MAPS_KEY
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html

14
nginx.conf Normal file
View File

@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri /index.html;
}
}

View File

@@ -1,13 +1,16 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon";
import CheckIcon from "./icons/CheckIcon";
import Toast from "./Toast";
import { getImageUrl } from "../config/authConstants";
import { CATEGORY_PET } from "../config/feedConstants";
import { getFoundAt, formatFoundDate } from "../utils/foundAt";
import { useContactSeller } from "../hooks/useContactSeller";
import { useAuthContext } from "../context/AuthContext";
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [index, setIndex] = useState(initialIndex);
const touchStart = useRef(null);
const swiped = useRef(false);
@@ -65,7 +68,17 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
contactSeller(onClose);
}, [contactSeller, onClose]);
const showContact = isAuthenticated && postData && !postData.isOwner && postData.status !== 'CLOSED';
const isClosed = postData?.status === 'CLOSED';
const showContact = isAuthenticated && postData && !postData.isOwner && !isClosed;
// Статус «найдено» держим на каждом слайде: перелистывая карусель, человек
// не должен терять из виду, что вещь/питомец уже вернулся к владельцу.
const foundText = postData?.category === CATEGORY_PET
? t('postCard.petFoundStatus')
: t('postCard.foundStatus');
const foundDate = isClosed ? formatFoundDate(getFoundAt(postData), i18n.language) : null;
const foundTitle = foundDate
? t('postCard.foundStatusWithDate', { status: foundText, date: foundDate })
: foundText;
if (!urls.length) return null;
@@ -81,11 +94,18 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
</button>
<div className="f-viewer">
{isClosed && (
<span className="f-viewer-found-badge" title={String(foundTitle)}>
<CheckIcon size={14}/>
<span>{foundText}</span>
</span>
)}
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
<img
src={urls[index]}
alt=""
className="f-viewer-img"
className={`f-viewer-img${isClosed ? ' f-viewer-img-closed' : ''}`}
/>
</div>

View File

@@ -0,0 +1,239 @@
/**
* Регрессия: первая загрузка маркеров должна уходить с границами карты.
*
* Раньше первый запрос висел на map.events.add('load'), которого у ymaps.Map
* в 2.1 нет — событие не наступало, fetch не уходил, а спиннер закрывал
* карту навсегда. Фейковый ymaps ниже намеренно НЕ умеет в 'load'.
*/
import { render, screen, waitFor, act } from "@testing-library/react";
import MapView from "./MapView";
// Реальный i18n, а не заглушка: MapView использует t() и без инициализации
// react-i18next сыплет предупреждение, а проверка текста ошибки теряла бы смысл.
import "../i18n";
jest.mock("../hooks/useGeolocation", () => ({
GEO_STATUS: {
IDLE: "idle",
LOADING: "loading",
SUCCESS: "success",
DENIED: "denied",
UNSUPPORTED: "unsupported",
TIMEOUT: "timeout",
},
// Геолокация в тестах не нужна и капризна (navigator.geolocation).
useGeolocation: () => ({ coords: null, status: "idle", requestLocation: jest.fn() }),
}));
const BOUNDS = [[55.7, 37.55], [55.8, 37.7]];
function installFakeYmaps({ boundsInitiallyNull = true } = {}) {
const listeners = {};
let bounds = boundsInitiallyNull ? null : BOUNDS;
const map = {
events: {
add(name, cb) {
(listeners[name] = listeners[name] || []).push(cb);
return { name, cb };
},
remove(sub) {
listeners[sub.name] = (listeners[sub.name] || []).filter((cb) => cb !== sub.cb);
},
},
geoObjects: { add() {}, removeAll() {} },
getBounds: () => bounds,
setCenter: jest.fn(),
destroy: jest.fn(),
};
window.ymaps = {
ready: (cb) => cb(),
Map: jest.fn(() => map),
GeoObjectCollection: jest.fn(function GeoObjectCollection() {
this.add = jest.fn();
this.removeAll = jest.fn();
}),
Placemark: jest.fn(function Placemark() {
this.events = { add() {} };
this.geometry = { setCoordinates() {} };
}),
templateLayoutFactory: { createClass: jest.fn(() => function Layout() {}) },
};
return {
map,
/** Имитирует первый расчёт вьюпорта — так ведёт себя настоящий API. */
publishBounds() {
bounds = BOUNDS;
act(() => {
(listeners.boundschange || []).forEach((cb) => cb());
});
},
panTo(next) {
bounds = next;
act(() => {
(listeners.boundschange || []).forEach((cb) => cb());
});
},
fireBoundsChangeOnly() {
act(() => {
(listeners.boundschange || []).forEach((cb) => cb());
});
},
};
}
let fetchMock;
beforeEach(() => {
jest.useFakeTimers();
fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{ id: 1, latitude: 55.75, longitude: 37.61, type: "LOST", title: "Кошелёк" },
],
});
global.fetch = fetchMock;
});
afterEach(() => {
jest.useRealTimers();
delete window.ymaps;
jest.clearAllMocks();
});
function mapCall(index = 0) {
const url = fetchMock.mock.calls[index]?.[0] || "";
return new URL(url, "http://localhost");
}
test("первый запрос уходит с границами карты, а не пустым query", async () => {
const ymaps = installFakeYmaps();
render(<MapView focusPostId={null} focusLat={NaN} focusLng={NaN} onBack={() => {}} />);
// Границы появляются только после первого расчёта вьюпорта.
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const url = mapCall();
expect(url.pathname).toBe("/api/v1/posts/map");
expect(url.searchParams.get("minLat")).toBe("55.7");
expect(url.searchParams.get("minLng")).toBe("37.55");
expect(url.searchParams.get("maxLat")).toBe("55.8");
expect(url.searchParams.get("maxLng")).toBe("37.7");
});
test("все четыре граничных параметра присутствуют в каждом запросе", async () => {
const ymaps = installFakeYmaps();
render(<MapView onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
for (const call of fetchMock.mock.calls) {
const url = new URL(call[0], "http://localhost");
for (const key of ["minLat", "maxLat", "minLng", "maxLng"]) {
expect(url.searchParams.get(key)).not.toBeNull();
}
}
});
test("опциональные фильтры type/category уезжают в том же запросе", async () => {
const ymaps = installFakeYmaps();
render(<MapView type="LOST" category="Электроника" onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const url = mapCall();
expect(url.searchParams.get("type")).toBe("LOST");
expect(url.searchParams.get("category")).toBe("Электроника");
expect(url.searchParams.get("minLat")).toBe("55.7");
});
test("фильтры не добавляются, когда не заданы", async () => {
const ymaps = installFakeYmaps();
render(<MapView onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const url = mapCall();
expect(url.searchParams.has("type")).toBe(false);
expect(url.searchParams.has("category")).toBe(false);
});
test("перемещение карты перезапрашивает маркеры один раз, несмотря на debounce", async () => {
const ymaps = installFakeYmaps();
render(<MapView onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
// Серия boundschange без пауз — должна схлопнуться в один запрос.
ymaps.fireBoundsChangeOnly();
ymaps.fireBoundsChangeOnly();
ymaps.fireBoundsChangeOnly();
expect(fetchMock).toHaveBeenCalledTimes(1);
await act(async () => {
jest.advanceTimersByTime(400);
});
// Новые границы = ровно один новый запрос.
ymaps.panTo([[55.9, 37.4], [56.0, 37.5]]);
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const url = mapCall(1);
expect(url.searchParams.get("minLat")).toBe("55.9");
expect(url.searchParams.get("maxLng")).toBe("37.5");
});
test("одинаковые границы не порождают повторный запрос", async () => {
const ymaps = installFakeYmaps();
render(<MapView onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
ymaps.fireBoundsChangeOnly();
await act(async () => {
jest.advanceTimersByTime(400);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("спиннер снимается и карта не остаётся закрыта оверлеем", async () => {
const ymaps = installFakeYmaps();
render(<MapView onBack={() => {}} />);
ymaps.publishBounds();
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
await waitFor(() => {
expect(screen.queryByTestId("mapview-loading")).toBeNull();
});
expect(screen.queryByText("Ошибка загрузки карты")).toBeNull();
});
test("даже если границы так и не пришли, карта не висит в вечной загрузке", async () => {
installFakeYmaps();
render(<MapView onBack={() => {}} />);
await waitFor(
() => {
expect(screen.queryByTestId("mapview-loading")).toBeNull();
},
{ timeout: 8000 }
);
});

View File

@@ -16,6 +16,7 @@ export default function FeedContent({
onEdit,
onDelete,
onMarkFound,
onReopen,
onImageClick,
onLocationClick,
onAuthRequired,
@@ -88,6 +89,7 @@ export default function FeedContent({
onEdit={activeNav === "myads" ? onEdit : undefined}
onDelete={activeNav === "myads" ? onDelete : undefined}
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
onReopen={activeNav === "myads" ? onReopen : undefined}
isOwner={activeNav === "myads"}
onImageClick={onImageClick}
onLocationClick={onLocationClick}

View File

@@ -18,6 +18,9 @@ interface FeedTopBarProps {
onMenuClick: () => void;
typeFilter: "loss" | "found" | null;
onTypeFilterChange: (key: "loss" | "found" | null) => void;
/** Фильтр состояния: null — все, "active" — активные, "closed" — найденные. */
statusFilter?: "active" | "closed" | null;
onStatusFilterChange?: (key: "active" | "closed" | null) => void;
hidden?: boolean;
/** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
topBarRef?: Ref<HTMLDivElement>;
@@ -37,6 +40,8 @@ export default function FeedTopBar({
onMenuClick,
typeFilter,
onTypeFilterChange,
statusFilter = null,
onStatusFilterChange,
hidden = false,
topBarRef,
}: FeedTopBarProps) {
@@ -51,6 +56,14 @@ export default function FeedTopBar({
{ key: "found", label: t("feed.filterFound") },
];
// «Активные / Найденные» — второй ряд того же дропдауна: найденные
// объявления по умолчанию лишь прижаты к низу, а этими вкладками их можно
// убрать из ленты (или посмотреть только их).
const statusOptions: { key: "active" | "closed"; label: string }[] = [
{ key: "active", label: t("feed.filterActive") },
{ key: "closed", label: t("feed.filterClosed") },
];
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@@ -70,6 +83,12 @@ export default function FeedTopBar({
setOpen(false);
};
const handleSelectStatus = (key: "active" | "closed") => {
// Повторный тап по активной вкладке сбрасывает фильтр в «все».
onStatusFilterChange?.(statusFilter === key ? null : key);
setOpen(false);
};
return (
<div className="f-topbar-dropdown-wrap">
<div className={`f-topbar${hidden ? " f-topbar-hidden" : ""}`} ref={topBarRef}>
@@ -142,6 +161,22 @@ export default function FeedTopBar({
{opt.label}
</button>
))}
{onStatusFilterChange && (
<>
<span className="f-topbar-dropdown-sep" aria-hidden="true" />
{statusOptions.map((opt) => (
<button
key={opt.key}
type="button"
aria-pressed={statusFilter === opt.key}
className={`f-topbar-dropdown-item${statusFilter === opt.key ? " active" : ""}`}
onClick={() => handleSelectStatus(opt.key)}
>
{opt.label}
</button>
))}
</>
)}
</motion.div>
)}
</AnimatePresence>

View File

@@ -0,0 +1,8 @@
export default function CheckIcon ({ size = 12 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@@ -0,0 +1,7 @@
// Статусы объявления — зеркало бэкенда (enum PostStatus).
//
// Раньше строка 'CLOSED' была захардкожена в четырёх местах (карточка,
// просмотрщик, хуки, админ-панель), и любая опечатка проходила тихо:
// карточка просто не показывала статус. Держим значение в одном месте.
export const POST_STATUS_ACTIVE = "ACTIVE";
export const POST_STATUS_CLOSED = "CLOSED";

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { useDeletePost } from "./useDeletePost";
import { usePostStatus } from "./usePostStatus";
import { rememberFoundAt, forgetFoundAt } from "../utils/foundAt";
/**
* Объединяет действия над объявлениями (удаление, отметка "найдено")
@@ -23,7 +24,7 @@ import { usePostStatus } from "./usePostStatus";
*/
export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthenticated, t }) {
const { deletePost } = useDeletePost();
const { markAsFound } = usePostStatus();
const { markAsFound, reopenPost } = usePostStatus();
const [error, setError] = useState("");
@@ -54,17 +55,37 @@ export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthent
if (!window.confirm(t('feed.confirmMarkFound'))) return;
const result = await markAsFound(postId);
if (result.success) {
// Бэкенд не отдаёт момент перехода в CLOSED, а карточке нужна дата
// («Найдено 3 октября») — запоминаем её на устройстве.
rememberFoundAt(postId);
afterMutation();
} else {
setError(result.error);
}
}, [t, markAsFound, afterMutation, isAuthenticated, onAuthRequired]);
// Отмена отметки «найдено» (автор нажал «Вернуть в поиск»). Без confirm:
// это восстановление, а не разрушительное действие.
const handleReopen = useCallback(async (postId) => {
if (!isAuthenticated) {
onAuthRequired?.();
return;
}
const result = await reopenPost(postId);
if (result.success) {
forgetFoundAt(postId);
afterMutation();
} else {
setError(result.error);
}
}, [reopenPost, afterMutation, isAuthenticated, onAuthRequired]);
const clearError = useCallback(() => setError(""), []);
return {
handleDelete,
handleMarkFound,
handleReopen,
handleNewAd: afterMutation,
handleEditSave: afterMutation,
error,

34
src/i18n.js Normal file
View File

@@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import translationRU from './locales/ru.json';
import translationEN from './locales/en.json';
const LANGUAGES = ['ru', 'en'];
// i18next-browser-languagedetector в проекте нет, поэтому persist делаем сами:
// иначе выбор языка жил только в памяти и сбрасывался при перезагрузке.
const stored = localStorage.getItem('i18nextLng');
const savedLang = LANGUAGES.includes(stored) ? stored : 'ru';
i18n.use(initReactI18next).init({
resources: {
ru: { translation: translationRU },
en: { translation: translationEN },
},
lng: savedLang,
fallbackLng: 'ru',
supportedLngs: LANGUAGES,
interpolation: { escapeValue: false },
});
const updateDocumentTitle = (lng) => {
document.title = lng === 'en' ? 'Find' : 'Находка';
};
i18n.on('languageChanged', (lng) => {
localStorage.setItem('i18nextLng', lng);
updateDocumentTitle(lng);
});
updateDocumentTitle(i18n.language);
export default i18n;

View File

@@ -153,6 +153,8 @@
"filterLoss": "Lost",
"filterAll": "All ads",
"filterFound": "Found",
"filterActive": "Active",
"filterClosed": "Closed",
"openFilter": "Filter by ad type",
"openMenu": "Open menu",
"filterCitiesPlaceholder": "Select city…",
@@ -170,7 +172,10 @@
"metro": "Metropolitan",
"loss": "Lost",
"found": "Found",
"foundStatus": "✓ Found",
"foundStatus": "Found",
"petFoundStatus": "Pet found! 🎉",
"foundStatusWithDate": "{{status}} — {{date}}",
"undoFound": "Return to search",
"write": "Write",
"itemFound": "✓ Returned to owner",
"reopen": "↩ Return to feed",

View File

@@ -153,6 +153,8 @@
"filterLoss": "Потери",
"filterAll": "Все объявления",
"filterFound": "Находки",
"filterActive": "Активные",
"filterClosed": "Найденные",
"openFilter": "Фильтр по типу объявления",
"openMenu": "Открыть меню",
"filterCitiesPlaceholder": "Выберите город…",
@@ -170,7 +172,10 @@
"metro": "Метрополитен",
"loss": "Потеря",
"found": "Находка",
"foundStatus": "✓ Найдено",
"foundStatus": "Найдено",
"petFoundStatus": "Питомец нашёлся! 🎉",
"foundStatusWithDate": "{{status}} — {{date}}",
"undoFound": "Вернуть в поиск",
"write": "Написать",
"itemFound": "✓ Вернулось к владельцу",
"reopen": "↩ Вернуть в ленту",

View File

@@ -29,6 +29,7 @@ import { useModals } from "../hooks/useModals";
import { useFeedActions } from "../hooks/useFeedActions";
import { useTranslation } from "react-i18next";
import { CATEGORY_ITEM, CATEGORY_OTHER, CATEGORY_PET } from "../config/feedConstants";
import { POST_STATUS_CLOSED } from "../config/postConstants";
import { useProfile } from "../hooks/useProfile";
import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger";
import FullscreenAdOverlay from "../components/FullscreenAdOverlay";
@@ -84,6 +85,10 @@ export default function MainFeed() {
const [locationError, setLocationError] = useState("");
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState<"loss" | "found" | null>(null);
// Фильтр состояния: null — все, "active" — только активные, "closed" — найденные.
// Найденные объявления по умолчанию остаются в ленте, но прижимаются к низу,
// а вкладки позволяют убрать их совсем — иначе люди ищут уже найденное.
const [statusFilter, setStatusFilter] = useState<"active" | "closed" | null>(null);
const [selectedCities, setSelectedCities] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const profileFetchedFor = useRef<number | null>(null);
@@ -393,8 +398,20 @@ export default function MainFeed() {
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
if (statusFilter === "closed") {
result = result.filter(ad => ad.status === POST_STATUS_CLOSED);
} else if (statusFilter === "active") {
result = result.filter(ad => ad.status !== POST_STATUS_CLOSED);
} else {
// Найденные — вниз. Сортировка стабильная, так что внутри групп порядок
// ленты (свежие сверху, бесконечный скролл) не ломается.
result = [...result].sort(
(a, b) => Number(a.status === POST_STATUS_CLOSED) - Number(b.status === POST_STATUS_CLOSED)
);
}
return result;
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
}, [activeNav, myPosts.myAds, cityFeed.cityAds, feed.ads, selectedCities, typeFilter, statusFilter]);
const listLoading = activeNav === "myads"
? myPosts.loading
@@ -433,6 +450,8 @@ export default function MainFeed() {
onMenuClick={() => setSidebarOpen(v => !v)}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
/>
)}
<div className="f-search-panel">
@@ -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}
/>
)}

View File

@@ -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 (
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${ad.type === "found" ? ' f-card-found' : ' f-card-loss'}`}>
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${ad.type === "found" ? ' f-card-found' : ' f-card-loss'}`}
role="group"
aria-label={isClosed ? `${ad.title}. ${foundAriaText}` : ad.title}>
<div className={`f-card-head${headAccentClass}`}>
<div className="f-card-author">
@@ -75,9 +89,6 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
</div>
<div className="f-card-head-side">
{typeBadge}
{isClosed && (
<span className="f-status-badge f-status-closed">{t('postCard.foundStatus')}</span>
)}
</div>
</div>
@@ -86,6 +97,14 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
? <img src={imageUrl} alt={ad.title} />
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
}
{/* Плашка статуса поверх фото: при быстром скролле шапка карточки
проезжает мимо, а фото с плашкой — нет. */}
{isClosed && (
<span className={`f-img-found-badge${isPet ? ' f-img-found-badge-pet' : ''}`}>
<CheckIcon size={12}/>
<span>{foundText}</span>
</span>
)}
</div>
{!compact && (
@@ -210,15 +229,28 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
</button>
</div>
)}
{onReopen && isOwner && isClosed && (
{isClosed && (
<div className="f-card-found-wrap">
<button type="button" className="f-card-found-btn f-card-reopen-btn"
{/* Кнопка «найдено» становится неактивной подтверждением: видно,
что действие уже применено, и повторно не нажать. */}
<button type="button" className="f-card-found-btn f-card-found-btn-done"
disabled
aria-label={foundAriaText}
title={foundAriaText}>
<CheckIcon size={14}/>
<span>{foundText}{foundDate ? ` ${foundDate}` : ''}</span>
</button>
{/* Отмена случайного нажатия — автору; в админ-панели это и есть
возврат объявления в ленту. */}
{onReopen && isOwner && (
<button type="button" className="f-card-found-undo"
onClick={e => {
e.stopPropagation();
onReopen(ad.id);
}}>
{t('postCard.reopen')}
{t('postCard.undoFound')}
</button>
)}
</div>
)}

View File

@@ -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 ── */

View File

@@ -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);

View File

@@ -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;

81
src/utils/foundAt.js Normal file
View File

@@ -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);
}
}

20
tsconfig.json Normal file
View File

@@ -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"]
}