Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97387b159 | ||
|
|
9d38a77904 | ||
|
|
1d3c5bcf0f | ||
|
|
bfbe84c9aa | ||
|
|
6dc61050d9 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.idea
|
||||||
16
.env.example
Normal file
16
.env.example
Normal 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=
|
||||||
24
.gitea/workflows/deploy.yml
Normal file
24
.gitea/workflows/deploy.yml
Normal 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
9
.npmrc
Normal 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
16
Dockerfile
Normal 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
14
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CloseIcon from "./icons/CloseIcon";
|
import CloseIcon from "./icons/CloseIcon";
|
||||||
|
import CheckIcon from "./icons/CheckIcon";
|
||||||
import Toast from "./Toast";
|
import Toast from "./Toast";
|
||||||
import { getImageUrl } from "../config/authConstants";
|
import { getImageUrl } from "../config/authConstants";
|
||||||
|
import { CATEGORY_PET } from "../config/feedConstants";
|
||||||
|
import { getFoundAt, formatFoundDate } from "../utils/foundAt";
|
||||||
import { useContactSeller } from "../hooks/useContactSeller";
|
import { useContactSeller } from "../hooks/useContactSeller";
|
||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
|
|
||||||
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
|
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [index, setIndex] = useState(initialIndex);
|
const [index, setIndex] = useState(initialIndex);
|
||||||
const touchStart = useRef(null);
|
const touchStart = useRef(null);
|
||||||
const swiped = useRef(false);
|
const swiped = useRef(false);
|
||||||
@@ -65,7 +68,17 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
|
|||||||
contactSeller(onClose);
|
contactSeller(onClose);
|
||||||
}, [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;
|
if (!urls.length) return null;
|
||||||
|
|
||||||
@@ -81,11 +94,18 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="f-viewer">
|
<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}>
|
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
|
||||||
<img
|
<img
|
||||||
src={urls[index]}
|
src={urls[index]}
|
||||||
alt=""
|
alt=""
|
||||||
className="f-viewer-img"
|
className={`f-viewer-img${isClosed ? ' f-viewer-img-closed' : ''}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
239
src/components/MapView.test.jsx
Normal file
239
src/components/MapView.test.jsx
Normal 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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export default function FeedContent({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMarkFound,
|
onMarkFound,
|
||||||
|
onReopen,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
onLocationClick,
|
onLocationClick,
|
||||||
onAuthRequired,
|
onAuthRequired,
|
||||||
@@ -88,6 +89,7 @@ export default function FeedContent({
|
|||||||
onEdit={activeNav === "myads" ? onEdit : undefined}
|
onEdit={activeNav === "myads" ? onEdit : undefined}
|
||||||
onDelete={activeNav === "myads" ? onDelete : undefined}
|
onDelete={activeNav === "myads" ? onDelete : undefined}
|
||||||
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
|
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
|
||||||
|
onReopen={activeNav === "myads" ? onReopen : undefined}
|
||||||
isOwner={activeNav === "myads"}
|
isOwner={activeNav === "myads"}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
onLocationClick={onLocationClick}
|
onLocationClick={onLocationClick}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interface FeedTopBarProps {
|
|||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
typeFilter: "loss" | "found" | null;
|
typeFilter: "loss" | "found" | null;
|
||||||
onTypeFilterChange: (key: "loss" | "found" | null) => void;
|
onTypeFilterChange: (key: "loss" | "found" | null) => void;
|
||||||
|
/** Фильтр состояния: null — все, "active" — активные, "closed" — найденные. */
|
||||||
|
statusFilter?: "active" | "closed" | null;
|
||||||
|
onStatusFilterChange?: (key: "active" | "closed" | null) => void;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
|
/** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
|
||||||
topBarRef?: Ref<HTMLDivElement>;
|
topBarRef?: Ref<HTMLDivElement>;
|
||||||
@@ -37,6 +40,8 @@ export default function FeedTopBar({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
typeFilter,
|
typeFilter,
|
||||||
onTypeFilterChange,
|
onTypeFilterChange,
|
||||||
|
statusFilter = null,
|
||||||
|
onStatusFilterChange,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
topBarRef,
|
topBarRef,
|
||||||
}: FeedTopBarProps) {
|
}: FeedTopBarProps) {
|
||||||
@@ -51,6 +56,14 @@ export default function FeedTopBar({
|
|||||||
{ key: "found", label: t("feed.filterFound") },
|
{ 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -70,6 +83,12 @@ export default function FeedTopBar({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectStatus = (key: "active" | "closed") => {
|
||||||
|
// Повторный тап по активной вкладке сбрасывает фильтр в «все».
|
||||||
|
onStatusFilterChange?.(statusFilter === key ? null : key);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="f-topbar-dropdown-wrap">
|
<div className="f-topbar-dropdown-wrap">
|
||||||
<div className={`f-topbar${hidden ? " f-topbar-hidden" : ""}`} ref={topBarRef}>
|
<div className={`f-topbar${hidden ? " f-topbar-hidden" : ""}`} ref={topBarRef}>
|
||||||
@@ -142,6 +161,22 @@ export default function FeedTopBar({
|
|||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
8
src/components/icons/CheckIcon.jsx
Normal file
8
src/components/icons/CheckIcon.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/config/postConstants.js
Normal file
7
src/config/postConstants.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Статусы объявления — зеркало бэкенда (enum PostStatus).
|
||||||
|
//
|
||||||
|
// Раньше строка 'CLOSED' была захардкожена в четырёх местах (карточка,
|
||||||
|
// просмотрщик, хуки, админ-панель), и любая опечатка проходила тихо:
|
||||||
|
// карточка просто не показывала статус. Держим значение в одном месте.
|
||||||
|
export const POST_STATUS_ACTIVE = "ACTIVE";
|
||||||
|
export const POST_STATUS_CLOSED = "CLOSED";
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useDeletePost } from "./useDeletePost";
|
import { useDeletePost } from "./useDeletePost";
|
||||||
import { usePostStatus } from "./usePostStatus";
|
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 }) {
|
export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthenticated, t }) {
|
||||||
const { deletePost } = useDeletePost();
|
const { deletePost } = useDeletePost();
|
||||||
const { markAsFound } = usePostStatus();
|
const { markAsFound, reopenPost } = usePostStatus();
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -54,17 +55,37 @@ export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthent
|
|||||||
if (!window.confirm(t('feed.confirmMarkFound'))) return;
|
if (!window.confirm(t('feed.confirmMarkFound'))) return;
|
||||||
const result = await markAsFound(postId);
|
const result = await markAsFound(postId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Бэкенд не отдаёт момент перехода в CLOSED, а карточке нужна дата
|
||||||
|
// («Найдено 3 октября») — запоминаем её на устройстве.
|
||||||
|
rememberFoundAt(postId);
|
||||||
afterMutation();
|
afterMutation();
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
}
|
}
|
||||||
}, [t, markAsFound, afterMutation, isAuthenticated, onAuthRequired]);
|
}, [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(""), []);
|
const clearError = useCallback(() => setError(""), []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleMarkFound,
|
handleMarkFound,
|
||||||
|
handleReopen,
|
||||||
handleNewAd: afterMutation,
|
handleNewAd: afterMutation,
|
||||||
handleEditSave: afterMutation,
|
handleEditSave: afterMutation,
|
||||||
error,
|
error,
|
||||||
|
|||||||
34
src/i18n.js
Normal file
34
src/i18n.js
Normal 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;
|
||||||
@@ -153,6 +153,8 @@
|
|||||||
"filterLoss": "Lost",
|
"filterLoss": "Lost",
|
||||||
"filterAll": "All ads",
|
"filterAll": "All ads",
|
||||||
"filterFound": "Found",
|
"filterFound": "Found",
|
||||||
|
"filterActive": "Active",
|
||||||
|
"filterClosed": "Closed",
|
||||||
"openFilter": "Filter by ad type",
|
"openFilter": "Filter by ad type",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"filterCitiesPlaceholder": "Select city…",
|
"filterCitiesPlaceholder": "Select city…",
|
||||||
@@ -170,7 +172,10 @@
|
|||||||
"metro": "Metropolitan",
|
"metro": "Metropolitan",
|
||||||
"loss": "Lost",
|
"loss": "Lost",
|
||||||
"found": "Found",
|
"found": "Found",
|
||||||
"foundStatus": "✓ Found",
|
"foundStatus": "Found",
|
||||||
|
"petFoundStatus": "Pet found! 🎉",
|
||||||
|
"foundStatusWithDate": "{{status}} — {{date}}",
|
||||||
|
"undoFound": "Return to search",
|
||||||
"write": "Write",
|
"write": "Write",
|
||||||
"itemFound": "✓ Returned to owner",
|
"itemFound": "✓ Returned to owner",
|
||||||
"reopen": "↩ Return to feed",
|
"reopen": "↩ Return to feed",
|
||||||
|
|||||||
@@ -153,6 +153,8 @@
|
|||||||
"filterLoss": "Потери",
|
"filterLoss": "Потери",
|
||||||
"filterAll": "Все объявления",
|
"filterAll": "Все объявления",
|
||||||
"filterFound": "Находки",
|
"filterFound": "Находки",
|
||||||
|
"filterActive": "Активные",
|
||||||
|
"filterClosed": "Найденные",
|
||||||
"openFilter": "Фильтр по типу объявления",
|
"openFilter": "Фильтр по типу объявления",
|
||||||
"openMenu": "Открыть меню",
|
"openMenu": "Открыть меню",
|
||||||
"filterCitiesPlaceholder": "Выберите город…",
|
"filterCitiesPlaceholder": "Выберите город…",
|
||||||
@@ -170,7 +172,10 @@
|
|||||||
"metro": "Метрополитен",
|
"metro": "Метрополитен",
|
||||||
"loss": "Потеря",
|
"loss": "Потеря",
|
||||||
"found": "Находка",
|
"found": "Находка",
|
||||||
"foundStatus": "✓ Найдено",
|
"foundStatus": "Найдено",
|
||||||
|
"petFoundStatus": "Питомец нашёлся! 🎉",
|
||||||
|
"foundStatusWithDate": "{{status}} — {{date}}",
|
||||||
|
"undoFound": "Вернуть в поиск",
|
||||||
"write": "Написать",
|
"write": "Написать",
|
||||||
"itemFound": "✓ Вернулось к владельцу",
|
"itemFound": "✓ Вернулось к владельцу",
|
||||||
"reopen": "↩ Вернуть в ленту",
|
"reopen": "↩ Вернуть в ленту",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useModals } from "../hooks/useModals";
|
|||||||
import { useFeedActions } from "../hooks/useFeedActions";
|
import { useFeedActions } from "../hooks/useFeedActions";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CATEGORY_ITEM, CATEGORY_OTHER, CATEGORY_PET } from "../config/feedConstants";
|
import { CATEGORY_ITEM, CATEGORY_OTHER, CATEGORY_PET } from "../config/feedConstants";
|
||||||
|
import { POST_STATUS_CLOSED } from "../config/postConstants";
|
||||||
import { useProfile } from "../hooks/useProfile";
|
import { useProfile } from "../hooks/useProfile";
|
||||||
import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger";
|
import { useFullscreenAdTrigger } from "../hooks/useFullscreenAdTrigger";
|
||||||
import FullscreenAdOverlay from "../components/FullscreenAdOverlay";
|
import FullscreenAdOverlay from "../components/FullscreenAdOverlay";
|
||||||
@@ -84,6 +85,10 @@ export default function MainFeed() {
|
|||||||
const [locationError, setLocationError] = useState("");
|
const [locationError, setLocationError] = useState("");
|
||||||
const [activeNav, setActiveNav] = useState("home");
|
const [activeNav, setActiveNav] = useState("home");
|
||||||
const [typeFilter, setTypeFilter] = useState<"loss" | "found" | null>(null);
|
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 [selectedCities, setSelectedCities] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const profileFetchedFor = useRef<number | null>(null);
|
const profileFetchedFor = useRef<number | null>(null);
|
||||||
@@ -393,8 +398,20 @@ export default function MainFeed() {
|
|||||||
if (typeFilter) {
|
if (typeFilter) {
|
||||||
result = result.filter(ad => ad.type === 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;
|
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"
|
const listLoading = activeNav === "myads"
|
||||||
? myPosts.loading
|
? myPosts.loading
|
||||||
@@ -433,6 +450,8 @@ export default function MainFeed() {
|
|||||||
onMenuClick={() => setSidebarOpen(v => !v)}
|
onMenuClick={() => setSidebarOpen(v => !v)}
|
||||||
typeFilter={typeFilter}
|
typeFilter={typeFilter}
|
||||||
onTypeFilterChange={setTypeFilter}
|
onTypeFilterChange={setTypeFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="f-search-panel">
|
<div className="f-search-panel">
|
||||||
@@ -483,6 +502,7 @@ export default function MainFeed() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={actions.handleDelete}
|
onDelete={actions.handleDelete}
|
||||||
onMarkFound={actions.handleMarkFound}
|
onMarkFound={actions.handleMarkFound}
|
||||||
|
onReopen={actions.handleReopen}
|
||||||
onImageClick={handleImageClick}
|
onImageClick={handleImageClick}
|
||||||
onLocationClick={handleLocationClick}
|
onLocationClick={handleLocationClick}
|
||||||
onAuthRequired={() => modals.open("auth")}
|
onAuthRequired={() => modals.open("auth")}
|
||||||
@@ -502,6 +522,8 @@ export default function MainFeed() {
|
|||||||
onMenuClick={() => setSidebarOpen(v => !v)}
|
onMenuClick={() => setSidebarOpen(v => !v)}
|
||||||
typeFilter={typeFilter}
|
typeFilter={typeFilter}
|
||||||
onTypeFilterChange={setTypeFilter}
|
onTypeFilterChange={setTypeFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
topBarRef={topBarRef}
|
topBarRef={topBarRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import PinIcon from "../components/icons/PinIcon";
|
|||||||
import CalendarIcon from "../components/icons/CalendarIcon";
|
import CalendarIcon from "../components/icons/CalendarIcon";
|
||||||
import PhoneIcon from "../components/icons/PhoneIcon";
|
import PhoneIcon from "../components/icons/PhoneIcon";
|
||||||
import TrashIcon from "../components/icons/TrashIcon";
|
import TrashIcon from "../components/icons/TrashIcon";
|
||||||
|
import CheckIcon from "../components/icons/CheckIcon";
|
||||||
import {useContactSeller} from "../hooks/useContactSeller";
|
import {useContactSeller} from "../hooks/useContactSeller";
|
||||||
import {useAuthContext} from "../context/AuthContext";
|
import {useAuthContext} from "../context/AuthContext";
|
||||||
import {getImageUrl} from "../config/authConstants";
|
import {getImageUrl} from "../config/authConstants";
|
||||||
|
import {CATEGORY_PET} from "../config/feedConstants";
|
||||||
|
import {getFoundAt, formatFoundDate} from "../utils/foundAt";
|
||||||
import DateCalendarPopup from "../components/DateCalendarPopup";
|
import DateCalendarPopup from "../components/DateCalendarPopup";
|
||||||
import RewardPopup from "../components/feed/RewardPopup";
|
import RewardPopup from "../components/feed/RewardPopup";
|
||||||
import Toast from "../components/Toast";
|
import Toast from "../components/Toast";
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen, isOwner, authorLabel, allowEditClosed, onImageClick, onLocationClick, onAuthRequired, compact }) {
|
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 [showCalendar, setShowCalendar] = useState(false);
|
||||||
const [showReward, setShowReward] = useState(false);
|
const [showReward, setShowReward] = useState(false);
|
||||||
const [descExpanded, setDescExpanded] = useState(false);
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
@@ -50,6 +53,15 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isClosed = ad.status === 'CLOSED';
|
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";
|
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 (
|
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-head${headAccentClass}`}>
|
||||||
<div className="f-card-author">
|
<div className="f-card-author">
|
||||||
@@ -75,9 +89,6 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
|
|||||||
</div>
|
</div>
|
||||||
<div className="f-card-head-side">
|
<div className="f-card-head-side">
|
||||||
{typeBadge}
|
{typeBadge}
|
||||||
{isClosed && (
|
|
||||||
<span className="f-status-badge f-status-closed">{t('postCard.foundStatus')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,6 +97,14 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
|
|||||||
? <img src={imageUrl} alt={ad.title} />
|
? <img src={imageUrl} alt={ad.title} />
|
||||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
: <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>
|
</div>
|
||||||
|
|
||||||
{!compact && (
|
{!compact && (
|
||||||
@@ -210,15 +229,28 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, onReopen
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onReopen && isOwner && isClosed && (
|
{isClosed && (
|
||||||
<div className="f-card-found-wrap">
|
<div className="f-card-found-wrap">
|
||||||
<button type="button" className="f-card-found-btn f-card-reopen-btn"
|
{/* Кнопка «найдено» становится неактивной подтверждением: видно,
|
||||||
onClick={e => {
|
что действие уже применено, и повторно не нажать. */}
|
||||||
e.stopPropagation();
|
<button type="button" className="f-card-found-btn f-card-found-btn-done"
|
||||||
onReopen(ad.id);
|
disabled
|
||||||
}}>
|
aria-label={foundAriaText}
|
||||||
{t('postCard.reopen')}
|
title={foundAriaText}>
|
||||||
|
<CheckIcon size={14}/>
|
||||||
|
<span>{foundText}{foundDate ? ` ${foundDate}` : ''}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Отмена случайного нажатия — автору; в админ-панели это и есть
|
||||||
|
возврат объявления в ленту. */}
|
||||||
|
{onReopen && isOwner && (
|
||||||
|
<button type="button" className="f-card-found-undo"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onReopen(ad.id);
|
||||||
|
}}>
|
||||||
|
{t('postCard.undoFound')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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-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); }
|
.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) ── */
|
/* ── Role badges (LOST_AND_FOUND / METRO) ── */
|
||||||
.f-role-badge {
|
.f-role-badge {
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
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;
|
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) ── */
|
/* ── Footer (phone + owner actions) ── */
|
||||||
.f-card-foot {
|
.f-card-foot {
|
||||||
display: flex; flex-direction: row; align-items: center; justify-content: space-between;
|
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);
|
border-color: rgba(var(--teal-rgb), .5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closed/Found card styling */
|
/* Closed/Found card styling.
|
||||||
|
Приглушение намеренно лёгкое: объявление найдено — это хорошая новость,
|
||||||
|
поэтому НЕ гасим карточку целиком (иначе она читается как удалённая).
|
||||||
|
Работают только фото (grayscale/brightness), текст остаётся читаемым. */
|
||||||
.f-card-closed {
|
.f-card-closed {
|
||||||
opacity: .8;
|
|
||||||
border-color: rgba(48,168,94,.3);
|
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 */
|
/* Edit & Delete buttons */
|
||||||
.f-card-edit-btn,
|
.f-card-edit-btn,
|
||||||
@@ -332,13 +357,28 @@ button.f-card-reward-btn:focus-visible {
|
|||||||
.f-card-found-btn:hover {
|
.f-card-found-btn:hover {
|
||||||
background: rgba(48,168,94,.18); border-color: rgba(48,168,94,.5);
|
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);
|
.f-card-found-btn-done {
|
||||||
color: var(--teal);
|
background: rgba(48,168,94,.14); border-color: rgba(48,168,94,.35);
|
||||||
|
color: #30a85e; opacity: 1; cursor: default;
|
||||||
}
|
}
|
||||||
.f-card-reopen-btn:hover {
|
.f-card-found-btn-done:hover {
|
||||||
background: rgba(var(--teal-rgb),.18); border-color: rgba(var(--teal-rgb),.5);
|
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 ── */
|
/* ── Calendar Popup ── */
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
.f-topbar-dropdown-menu {
|
.f-topbar-dropdown-menu {
|
||||||
position: absolute; top: 100%; left: 0; right: 0;
|
position: absolute; top: 100%; left: 0; right: 0;
|
||||||
z-index: 50;
|
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;
|
padding: 8px 12px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
background: rgba(0, 0, 0, .72);
|
background: rgba(0, 0, 0, .72);
|
||||||
@@ -292,6 +292,14 @@
|
|||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
|
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 {
|
:root:not([data-theme="dark"]) .f-topbar-dropdown-menu {
|
||||||
background: rgba(255, 255, 255, .92);
|
background: rgba(255, 255, 255, .92);
|
||||||
border-color: rgba(0, 0, 0, .08);
|
border-color: rgba(0, 0, 0, .08);
|
||||||
|
|||||||
@@ -214,6 +214,25 @@ select.f-m-field::selection { background: transparent; }
|
|||||||
box-shadow: 0 8px 40px rgba(0,0,0,.5);
|
box-shadow: 0 8px 40px rgba(0,0,0,.5);
|
||||||
touch-action: pan-y;
|
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 {
|
.f-viewer-contact {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 10px 28px;
|
padding: 10px 28px;
|
||||||
|
|||||||
81
src/utils/foundAt.js
Normal file
81
src/utils/foundAt.js
Normal 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
20
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user