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 { 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>
|
||||
|
||||
|
||||
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,
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 { 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
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",
|
||||
"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",
|
||||
|
||||
@@ -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": "↩ Вернуть в ленту",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onReopen(ad.id);
|
||||
}}>
|
||||
{t('postCard.reopen')}
|
||||
{/* Кнопка «найдено» становится неактивной подтверждением: видно,
|
||||
что действие уже применено, и повторно не нажать. */}
|
||||
<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.undoFound')}
|
||||
</button>
|
||||
)}
|
||||
</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-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 ── */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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