Compare commits

...

15 Commits

Author SHA1 Message Date
SlimusMinus
b97387b159 Fix home name
All checks were successful
deploy-frontend / deploy (push) Successful in 11s
2026-10-08 00:27:51 +03:00
SlimusMinus
9d38a77904 Test auto deploy
All checks were successful
deploy-frontend / deploy (push) Successful in 45s
2026-10-07 23:37:02 +03:00
SlimusMinus
1d3c5bcf0f Add Docker build and deploy pipeline
All checks were successful
deploy-frontend / deploy (push) Successful in 1m58s
2026-10-07 23:31:51 +03:00
SlimusMinus
bfbe84c9aa Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 5s
2026-10-07 23:29:38 +03:00
SlimusMinus
6dc61050d9 Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 11s
2026-10-07 23:27:38 +03:00
SlimusMinus
6791ee6eb3 Merge pull request #77 from SlimusMinus/add-reject_post
added rejected posts for admin
2026-10-06 02:07:55 +03:00
SlimusMinus
0074e51829 added rejected posts for admin 2026-10-06 02:06:44 +03:00
SlimusMinus
9ccad319cd Merge pull request #76 from SlimusMinus/fix-lan_lat_post
fixed lan and lon on post
2026-10-06 01:25:37 +03:00
SlimusMinus
bd3f997677 fixed lan and lon on post 2026-10-06 01:24:28 +03:00
SlimusMinus
3cfe389144 Merge pull request #75 from SlimusMinus/fix-all_code
fix bug and errors
2026-10-06 01:00:50 +03:00
SlimusMinus
d94a6782a4 fix bug and errors 2026-10-06 01:00:10 +03:00
SlimusMinus
73e3634ec4 Merge pull request #74 from SlimusMinus/fix-admin_panel
added all feeds for admin
2026-10-02 01:51:26 +03:00
SlimusMinus
841bf091d4 added all feeds for admin 2026-10-02 01:50:42 +03:00
SlimusMinus
510bfc06f2 Merge pull request #73 from SlimusMinus/added-box_map
fixed map by redis
2026-09-29 01:07:43 +03:00
SlimusMinus
a4a9f955d8 fixed map by redis 2026-09-29 01:06:59 +03:00
77 changed files with 2933 additions and 2913 deletions

4
.dockerignore Normal file
View File

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

3
.env
View File

@@ -1,3 +0,0 @@
REACT_APP_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3
REACT_APP_YANDEX_MAPS_KEY=d4696cc8-6a1f-4356-9219-108ac474093b
REACT_APP_API_URL=http://192.168.1.75:8080

16
.env.example Normal file
View File

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

View File

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

4
.gitignore vendored
View File

@@ -13,6 +13,10 @@
# misc # misc
.DS_Store .DS_Store
# env — реальные ключи и адреса бэкенда не коммитим.
# Образец переменных держим в .env.example
.env
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local

9
.npmrc Normal file
View File

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

16
Dockerfile Normal file
View File

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

620
README.md
View File

@@ -1,70 +1,614 @@
# Getting Started with Create React App # Находка
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). Платформа для поиска потерянных вещей и питомцев. SPA на React 18 с JWT-аутентификацией
и автоматическим обновлением токена, чатом на WebSocket (STOMP/SockJS), картами Яндекса,
загрузкой изображений, админ-панелью рекламы и модерации объявлений, i18n (ru/en) и
светлой/тёмной темой.
## Available Scripts ---
In the project directory, you can run: ## Содержание
### `npm start` 1. [Быстрый старт](#1-быстрый-старт)
2. [Переменные окружения](#2-переменные-окружения)
3. [Стек и зависимости](#3-стек-и-зависимости)
4. [Скрипты](#4-скрипты)
5. [Структура проекта](#5-структура-проекта)
6. [Маршруты](#6-маршруты)
7. [Ключевые компоненты](#7-ключевые-компоненты)
8. [Контексты](#8-контексты)
9. [Хуки](#9-хуки)
10. [API-эндпоинты](#10-api-эндпоинты)
11. [WebSocket](#11-websocket)
12. [Админские эндпоинты](#12-админские-эндпоинты)
13. [Стили и темы](#13-стили-и-темы)
14. [Основные сценарии](#14-основные-сценарии)
15. [Известные проблемы и предупреждения](#15-известные-проблемы-и-предупреждения)
Runs the app in the development mode.\ ---
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\ ## 1. Быстрый старт
You may also see any lint errors in the console.
### `npm test` Требуется Node.js 18+ (проверено на Node 24).
Launches the test runner in the interactive watch mode.\ ```bash
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. npm install # зависимости (см. раздел о .npmrc)
npm start # dev-сервер на http://localhost:3000
npm run build # production-сборка в build/
```
### `npm run build` Фронтенд ожидает работающий бэкенд (по умолчанию `http://localhost:8080`).
Если нужно обращаться к другому адресу — скопируйте `.env.example` в `.env`
и задайте `REACT_APP_API_URL`.
Builds the app for production to the `build` folder.\ ---
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\ ## 2. Переменные окружения
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. Файл `.env` **не** коммитится (см. `.gitignore`), шаблон — `.env.example`.
### `npm run eject` | Переменная | Обязательна | По умолчанию | Описание |
|------------|-------------|--------------|----------|
| `REACT_APP_API_URL` | да | `http://localhost:8080` | Базовый URL бэкенда |
| `REACT_APP_YANDEX_GEOCODER_KEY` | для карты | — | Яндекс.Геокодер: обратное геокодирование координат → адрес |
| `REACT_APP_YANDEX_MAPS_KEY` | для карты | — | Публичный ключ Яндекс.Карт, подставляется в `public/index.html` |
| `REACT_APP_SUPPORT_EMAIL` | нет | есть значение по умолчанию | Почта поддержки в модалке «Поддержка» |
**Note: this is a one-way operation. Once you `eject`, you can't go back!** > **Безопасность.** Ключи Яндекса — публичные, но попадание реального `.env` в git
> означает утечку в историю коммитов. Если файл уже коммитился, перевыпустите ключи:
> добавление `.env` в `.gitignore` не удаляет его из истории.
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. ---
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. ## 3. Стек и зависимости
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. | Пакет | Назначение |
|-------|-----------|
| `react` / `react-dom` 18 | UI |
| `react-router-dom` 7 | Роутинг (`BrowserRouter`) |
| `@stomp/stompjs` + `sockjs-client` | WebSocket (STOMP) для чата и непрочитанных |
| `framer-motion` | Анимации модалок и переходов |
| `react-hook-form` + `@hookform/resolvers` + `zod` 4 | Формы и их валидация |
| `i18next` + `react-i18next` | Локализация ru/en |
| `@emoji-mart/react` + `@emoji-mart/data` + `emoji-mart` | Эмодзи-пикер в чате |
| `react-scripts` 5.0.1 | Сборка/дев-сервер (CRA) |
## Learn More Карты Яндекса подключаются напрямую через глобальный `window.ymaps`
(см. `src/utils/ymapsApi.js`), без обёртки — она ждёт готовности API
с таймаутом и отдаёт ошибку вместо вечного спиннера.
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). Dev-зависимости: `typescript` 5.9, `@types/react` 18, `@types/react-dom`,
`@types/node`, `@testing-library/*`.
To learn React, check out the [React documentation](https://reactjs.org/). ### `.npmrc` и конфликт peer-зависимостей
### Code Splitting В репозитории лежит `.npmrc` с `legacy-peer-deps=true`. Причина: `react-scripts@5.0.1`
объявляет `typescript` **опциональным** peer-диапазоном `^3.2.1 || ^4`, а проекту нужен
TypeScript 5 (на TS 4 не собираются `@types/react` 18.3 и `zod` 4). Конфликт формальный:
CRA типы не проверяет — их вырезает babel, а проверка запускается отдельно через
`npm run typecheck`.
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) Побочный эффект `legacy-peer-deps`: npm не ставит peer-зависимости автоматически,
поэтому `emoji-mart` объявлен в `dependencies` явно (его требует `@emoji-mart/react`).
### Analyzing the Bundle Size ---
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) ## 4. Скрипты
### Making a Progressive Web App | Скрипт | Что делает |
|--------|-----------|
| `npm start` | Dev-сервер с hot reload |
| `npm run build` | Production-сборка в `build/` |
| `npm test` | Jest в watch-режиме |
| `npm run test:ci` | Тесты один раз (`--watchAll=false`) — для CI |
| `npm run lint` | ESLint по `src`, `--max-warnings=0` |
| `npm run typecheck` | `tsc --noEmit` по `tsconfig.json` |
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) Перед коммитом стоит прогнать все четыре проверки: `lint`, `typecheck`, `test:ci`, `build`.
### Advanced Configuration ---
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) ## 5. Структура проекта
### Deployment ```
src/
├── index.js # Точка входа
├── App.js # Провайдеры + маршруты
├── i18n.js # Инициализация i18next, persist языка
├── legacy.d.ts # Типы ассетов (*.css, *.png, ...)
│
├── api/
│ ├── apiClient.jsx # apiFetch: Authorization + refresh токена по 401
│ ├── adminAdsApi.js # Кампании, реклама, фуллскрин-реклама
│ ├── adminContentApi.js # Админские объявления: список, статус, удаление
│ └── adsApi.js # Фуллскрин-реклама, пресigned-выгрузка видео
│
├── config/
│ ├── authConstants.js # API_URL
│ ├── feedConstants.js # Пункты меню, категории, пустая форма
│ └── landingConstants.js # Контент лендинга
│
├── context/
│ ├── AuthContext.jsx # user, token, isAuthenticated, isAdmin, login/logout
│ ├── ThemeContext.jsx # Светлая/тёмная тема
│ └── UnreadContext.jsx # Счётчики непрочитанных чатов
│
├── stores/
│ ├── activeChatStore.js # Активный чат
│ └── chatPostStore.js # Кэш «объявление ↔ чат»
│
├── hooks/ # см. раздел 9
│
├── utils/
│ ├── adSession.js # ID рекламной сессии (устойчив к запрету localStorage)
│ ├── campaignStatus.js # Статусы кампаний
│ ├── chatDisplay.jsx # Данные для списка чатов
│ ├── compressImage.js # Сжатие JPEG до 1200px
│ ├── formatPhone.js # Маска +7 (___) ___-__-__
│ ├── validatePostForm.js # Валидация формы объявления
│ ├── withAds.js # Рекламная обёртка
│ └── ymapsApi.js # Ожидание window.ymaps с таймаутом
│
├── pages/
│ ├── NakhodkaLanding.jsx # Лендинг «/»
│ ├── MainFeed.tsx # Лента объявлений — главная страница
│ ├── MapModal.jsx, MapView.jsx # Карта и полноэкранный режим
│ ├── ChatListPage.jsx, ChatPage.jsx
│ ├── ReviewsPage.jsx
│ ├── CreateUpdatePost.jsx # Создание/редактирование (в т.ч. adminMode)
│ ├── PostOnFeed.jsx # Карточка объявления
│ ├── RulesModal.jsx, SupportModal.jsx
│ └── AdminAdsPage.jsx # Админ-панель
│
├── components/
│ ├── feed/ FeedTopBar.tsx, FeedContent, FeedSearchBar, PullToRefresh, RewardPopup
│ ├── nav/ BottomNav.jsx, Sidebar.jsx
│ ├── layout/ AppTopBar.jsx
│ ├── chat/ ChatHeader, ChatInputBar, ChatListItem, MessageBubble
│ ├── reviews/ RatingSummary, ReviewCard, ReviewForm, StarRating
│ ├── profile/ Profile, ProfileAvatar, ProfileFormFields, ProfileModal, profileValidation
│ ├── admin/ PostsTab, AdvertisingCreate/EditModal, CampaignCreate/EditModal, ...
│ ├── landing/ Hero, Navbar, StepsSection, RadarBeacon, FinalCta, Footer, Icon
│ ├── icons/ NavIcons + Calendar/Close/Globe/Menu/Moon/Phone/Pin/Sun/Trash
│ └── … AuthModal, Toast, MapView, PostDetailModal, ImageViewer, AdCard, AdSlot, ...
│
├── styles/
│ ├── index.css # Точка входа CSS
│ ├── base.css # Переменные, сброс, keyframes
│ ├── layout.css, sidebar.css, card.css, modal.css, ui.css
│ └── chat.css, reviews.css, rules.css, admin.css, NakhodkaLanding.css
│
└── locales/
├── ru.json
└── en.json
```
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) ---
### `npm run build` fails to minify ## 6. Маршруты
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) | Путь | Компонент | Доступ |
|------|-----------|--------|
| `/` | `NakhodkaLanding` | публичный лендинг |
| `/feed` | `MainFeed` | лента (главная) |
| `/reviews` | `ReviewsPage` | отзывы о площадке |
| `/chats` | `ChatListPage` | список чатов |
| `/chats/:chatId` | `ChatPage` | чат |
| `/admin/ads` | `AdminAdsPage` | только `isAdmin` (иначе редирект на `/feed`) |
| `*` | `NakhodkaLanding` | 404-fallback |
Провайдеры: `ThemeProvider` → `AuthProvider` → `BrowserRouter`.
---
## 7. Ключевые компоненты
### `MainFeed.tsx`
Главная страница. Собирает топбар, поиск, фильтры, ленту, сайдбары, карту,
модалки (объявление, профиль, вход, правила, поддержка, просмотр фото).
Делегирует загрузку и мутации хукам `useFeedPosts`, `useCityFeed`, `useSearchPosts`,
`useMyPosts`, `useFeedActions`, `useModals`. Ошибки удаления/отметки «найдено»
показываются через `Toast`.
### `PostOnFeed.jsx`
Карточка объявления: фото, бейджи категории/типа/статуса, заголовок, описание,
адрес, дата, телефон, кнопка карты и «Написать». Для владельца — редактирование,
удаление, отметка «найдено».
### `CreateUpdatePost.jsx`
Модалка создания/редактирования. Переключатель Потеря/Находка, заголовок,
описание, фото (сжатие на клиенте), категория, карта Яндекса с меткой и обратным
геокодированием, адрес, телефон с маской, чекбокс правил. Валидация —
`validatePostForm`. С пропом `adminMode` шлёт `PUT /api/v1/admin/posts/{id}`.
### `FeedContent.jsx` / `FeedTopBar.tsx` / `PullToRefresh.jsx`
Лента, топбар со спрятанной панелью на скролле, pull-to-refresh с корректным
cleanup класса активности.
### `MapView.jsx`
Полноэкранная карта. Ждёт готовности API Яндекса, показывает плашку ошибки,
если он не загрузился. Первая загрузка маркеров идёт по фактическим границам
карты, а не по событию `load` (которого у `ymaps.Map` в 2.1 нет).
### `AdminAdsPage.jsx` + `components/admin/PostsTab.jsx`
Админ-панель: вкладки «Рекламные кампании» и «Все объявления».
Кампании, реклама, загрузка видео (presigned → S3 → confirm), фуллскрин-реклама.
`PostsTab` — список всех объявлений с поиском, фильтрами, пагинацией, правкой,
закрытием/возвратом в ленту и удалением.
---
## 8. Контексты
### `AuthContext`
```js
{
user, // object | null
token, // string | null — JWT
isAuthenticated, // !!token
isAdmin, // user.role === "ADMIN" — только скрытие UI, не защита
login(user, token), // пишет в localStorage + state
logout(), // чистит localStorage + state
updateUser(userData), // обновляет пользователя
}
```
Хранится в `localStorage`: `user` (JSON), `token`, `refreshToken`.
Хук: `useAuthContext()`.
### `ThemeContext`
Светлая/тёмная тема; выбор сохраняется.
### `UnreadContext`
Счётчики непрочитанных по чатам; сбрасываются при смене пользователя,
данные приходят из WS-подписки `/topic/user/{userId}/unread`.
---
## 9. Хуки
### Авторизация и API
| Хук | Назначение |
|-----|-----------|
| `apiClient.jsx` → `apiFetch` | `fetch` + `Authorization` + автоматический refresh по 401 (один refresh на параллельные запросы) |
| `useProfile.js` | Загрузка профиля |
| `useUpdateProfile.js` | Обновление профиля с аватаром |
### Объявления
| Хук | Назначение |
|-----|-----------|
| `useFeedPosts.jsx` | Лента с пагинацией, request-id guard, дедупликация |
| `useCityFeed.jsx` | Лента по городу |
| `useSearchPosts.js` | Поиск с дебаунсом |
| `useMyPosts.jsx` | Мои объявления |
| `useFeedActions.jsx` | Удаление и отметка «найдено» + `error`/`clearError` |
| `usePostMutation.js` | Создание/обновление (обычное и `adminMode`) |
| `useDeletePost.js`, `usePostStatus.js` | Точечные мутации |
| `useImageUpload.js` | Выбор, сжатие, превью изображения |
| `useModals.jsx` | Управление модалками |
| `useInfiniteScroll.jsx` | Бесконечная прокрутка с cleanup `IntersectionObserver` |
| `useCities.js`, `useClickOutside.jsx`, `useIsDesktop.js` | Вспомогательные |
### Реклама
| Хук | Назначение |
|-----|-----------|
| `useFullscreenAdTrigger.js` | Показ фуллскрин-рекламы |
| `useAdClickTracking.js`, `useAdImpressionTracking.js` | Клики/показы |
| `useTranscodingStatusPolling.js` | Статус транскодинга видео: сброс между роликами, fallback `PROCESSING`, лимит попыток |
### Чат
| Хук | Назначение |
|-----|-----------|
| `useStompClient.js` | Общий STOMP-клиент: reconnect, обработка close/ошибок, стабильные колбэки |
| `useChatSocket.js` | Подписка на чат, отправка сообщений |
| `useUnreadSocket.js` | Непрочитанные |
| `useChats.js` | Список чатов, создание, сообщения (request-id guard) |
| `useContactSeller.js` | Создание/получение чата с продавцом |
| `useDeleteChat.js` | Удаление чата |
### Карта и локация
| Хук | Назначение |
|-----|-----------|
| `useGeolocation.js` | Геолокация пользователя со статусами |
| `useYandexMap.js` | Инициализация карты, метка (drag), обратное геокодирование, ошибка загрузки API |
### Прочее
| Хук | Назначение |
|-----|-----------|
| `useReviews.jsx` | Отзывы: загрузка, отправка, средний рейтинг и распределение |
| `useLanguageToggle.js` | Переключение ru/en |
---
## 10. API-эндпоинты
Базовый URL — `REACT_APP_API_URL`. Защищённые эндпоинты вызываются через
`apiFetch`, который сам добавляет `Authorization: Bearer <JWT>` и обновляет
токен при 401.
### Аутентификация
| Метод | Endpoint | Тело | Ответ |
|-------|----------|------|-------|
| `POST` | `/api/v1/auth/login` | `{ login, password }` | `{ token, refreshToken, user }` |
| `POST` | `/api/v1/auth/register` | `{ name, login, password }` | `{ token, refreshToken, user }` |
| `POST` | `/api/v1/auth/refresh` | `{ refreshToken }` | `{ token, refreshToken }` |
### Объявления
| Метод | Endpoint | Назначение |
|-------|----------|-----------|
| `GET` | `/api/v1/posts?page=&size=&…` | Лента с пагинацией (`content` / `items` / массив) |
| `GET` | `/api/v1/posts/search?…` | Поиск |
| `GET` | `/api/v1/posts/cities` | Список городов |
| `GET` | `/api/v1/posts/map?minLat&maxLat&minLng&maxLng&…` | Маркеры в границах карты |
| `GET` | `/api/v1/posts/detail/:postId` | Карточка объявления |
| `GET` | `/api/v1/posts/:userId` | Объявления пользователя |
| `POST` | `/api/v1/posts` | Создать (`FormData`) |
| `PUT` | `/api/v1/posts/:id` | Обновить (`FormData`) |
| `DELETE` | `/api/v1/posts/:id` | Удалить |
| `PATCH` | `/api/v1/posts/:postId/status` | Статус `ACTIVE` / `CLOSED` |
### Профиль
| Метод | Endpoint | Назначение |
|-------|----------|-----------|
| `PUT` | `/api/v1/users/:userId` | Обновить профиль (`FormData`) |
### Чаты
| Метод | Endpoint | Назначение |
|-------|----------|-----------|
| `GET` | `/api/v1/chats` | Список чатов |
| `POST` | `/api/v1/chats/with/:otherUserId` | Создать/получить чат (`{ postId }`) |
| `GET` | `/api/v1/chats/:chatId/messages` | Сообщения |
| `POST` | `/api/v1/chats/:chatId/messages` | Отправить |
| `POST` | `/api/v1/chats/:chatId/read` | Отметить прочитанным |
| `DELETE` | `/api/v1/chats/:chatId` | Удалить чат |
### Отзывы
| Метод | Endpoint | Тело |
|-------|----------|------|
| `GET` | `/api/v1/reviews` | — |
| `POST` | `/api/v1/reviews` | `{ rating, text }` |
### Файлы и реклама
| Метод | Endpoint | Назначение |
|-------|----------|-----------|
| `GET` | `/api/v1/files/:filename` | Изображение по имени |
| `GET` | `/api/fullscreen-ad` | Текущая фуллскрин-реклама |
| `GET` | `/api/ads/next` | Следующая реклама |
| `POST` | `/api/ads/click`, `/api/ads/impression` | Трекинг |
Значения категорий в запросах — **стабильные русские строки** (`Вещь`, `Питомец`,
`Другое`): они хранятся в БД, поэтому не переводятся вместе с интерфейсом.
Локализованы только подписи (`feedConstants.js` → `CATEGORIES`).
---
## 11. WebSocket
STOMP поверх SockJS: `${REACT_APP_API_URL}/ws` (токен передаётся при подключении).
| Назначение | Путь |
|-----------|------|
| Подписка на чат | `/topic/chat/{chatId}` |
| Подписка на непрочитанные | `/topic/user/{userId}/unread` |
| Отправка сообщения | `/app/chat.send` |
Общий клиент — `useStompClient`: единый STOMP-клиент с reconnect, обработкой
закрытия соединения и ошибок активации; колбэки хранятся в ref, поэтому
переподписка не пересоздаёт подключение.
---
## 12. Админские эндпоинты
Админ-панель живёт на `/admin/ads` (только для `isAdmin`, только десктоп) и
переключается вкладками:
- `/admin/ads` — рекламные кампании;
- `/admin/ads?tab=posts` — «Все объявления».
### Общие требования
- Префикс `/api/v1/admin`, все запросы с `Authorization: Bearer <JWT>`.
- **Роль `ADMIN` обязана проверяться на сервере по каждому эндпоинту.**
Клиентский `isAdmin` — только скрытие UI; подделка `localStorage` не должна
давать доступ к чужим объявлениям и переписке.
- Тело ошибок — JSON `{"message": "..."}`; фронт показывает этот текст.
- Пагинация как у `GET /api/v1/posts`: `{ "content": [...], "last": bool, "totalElements": N }`.
Фронт принимает и `content`, и `items`, и голый массив.
### Реклама (работает)
`/api/v1/admin/campaigns`, `/api/v1/admin/ads`, `/api/v1/admin/fullscreen-ads`.
### Объявления
#### `GET /api/v1/admin/posts`
Параметры (все опциональны): `page` (с 0), `size` (по умолчанию 20, максимум 100),
`search`, `type` (`LOSS`/`FOUND`), `status`.
Видны **все** статусы, включая `MODERATION` и `REJECTED`. Сортировка по умолчанию —
новые сверху (`createdAt DESC`). `search` ищет без учёта регистра по названию,
описанию, городу, району и данным владельца (login, имя, фамилия).
Элемент `content` — то же, что у публичного объявления, плюс данные автора
(`ownerId`, `ownerLogin`, `ownerName`, `ownerFirstName`, `ownerLastName`).
Фронт читает автора с запасом (`ownerLogin` / `authorLogin` / `userLogin` и т. п.).
#### `PUT /api/v1/admin/posts/{postId}`
`multipart/form-data`, как у пользовательского `PUT /api/v1/posts/{id}`:
`type`, `title`, `description`, `category`, `phone`, `reward`, `rewardText`,
`images`, `existingImages`, `latitude`, `longitude`, `address`.
Ответ `200` — обновлённое объявление.
Отличия от пользовательской правки:
- **Статус сохраняется** — админ чинит объявление, не убирая его из ленты
(`MODERATION` отправил бы на повторную AI-модерацию, `REJECTED` скрыл бы навсегда).
Смена статуса — отдельным `PATCH`.
- Падение геокодинга не блокирует правку: город берётся из `address`.
- Изображения, которых нет в `existingImages`, удаляются.
#### `DELETE /api/v1/admin/posts/{postId}`
Ответ `204 No Content`. Вместе с объявлением чистятся изображения в хранилище,
записи из `favorites` и обнуляется `post_id` в чатах.
#### `PATCH /api/v1/admin/posts/{postId}/status`
Тело `{ "status": "CLOSED" }` — закрыть («найдено»), `{ "status": "ACTIVE" }` —
вернуть в ленту. Принимаются все `PostStatus`: `ACTIVE`, `CLOSED`, `MODERATION`,
`REJECTED` (фронт отдаёт первые два). Переход в `CLOSED` увеличивает `posts_found`.
Неизвестный статус — `400`.
### Чаты (вкладка не сделана)
Требуемые эндпоинты:
| Метод | Endpoint | Назначение |
|-------|----------|-----------|
| `GET` | `/api/v1/admin/chats?page&size&search&postId` | Список чатов с превью |
| `GET` | `/api/v1/admin/chats/{chatId}/messages` | Сообщения чата |
| `POST` | `/api/v1/admin/chats/{chatId}/messages` | Написать в чат (`{ "content": "…" }`) |
| `DELETE` | `/api/v1/admin/chats/{chatId}` | Удалить чат с перепиской |
**Важно:** `senderId` должен браться **из JWT, а не из тела запроса**, иначе админ
мог бы слать сообщения от имени пользователей. Авторизация — только `ADMIN`;
это осознанное расширение прав по сравнению с пользовательским чатом
(например, чтобы предупредить о мошенничестве).
### Что сделано на клиенте
- `src/api/adminContentApi.js` — `getAdminPosts`, `deleteAdminPost`,
`updateAdminPostStatus`; пагинация принимает `content` / `items` / массив;
`type` нормализуется в `loss`/`found`.
- `CreateUpdatePost` с пропом `adminMode` → `usePostMutation` шлёт
`PUT /api/v1/admin/posts/{id}` (форма и валидация общие с пользовательской).
- `src/components/admin/PostsTab.jsx` — список, поиск с дебаунсом, фильтры,
«Показать ещё», карточки с действиями.
### Что осталось на клиенте
- Вкладка «Все чаты» (`ChatsTab.jsx` не создана) — ждёт эндпоинтов чатов.
- Проверка роли и скрытие UI: `AuthContext.isAdmin`, `AdminRoute` в `App.js`,
`isAdmin` в сайдбаре. Это удобство, не защита.
- Админка не постит сообщения через STOMP: используется REST, чтобы реплика
не зависела от того, разрешена ли админу подписка `/topic/chat/{id}`.
### Статус бэкенда
Репозиторий бэкенда: `D:\reFound\ReFound`.
| Файл | Назначение |
| --- | --- |
| `controller/admin/PostAdminController.java` | Эндпоинты объявлений, у каждого `@PreAuthorize("hasRole('ADMIN')")` |
| `service/admin/AdminPostService.java` | Логика: список, правка, удаление, статус |
| `dto/admin/AdminPostResponse.java` | Объявление + данные владельца |
| `dto/admin/AdminPostStatusUpdateRequest.java` | `{ "status": "..." }` |
| `util/PostSpecification.java` | `adminSearch` — поиск по полям объявления и владельца |
| `repository/ImageRepository.java` | `findByPostIdIn` — картинки страницы одним запросом вместо N+1 |
| `repository/ChatRepository.java` | `clearPostReference` — обнуление `post_id` при удалении |
| `test/.../AdminPostServiceTest.java` | 13 тестов: поиск, фильтры, статус, статистика, картинки, кэш |
Защита дублируется: `SecurityConfig` требует `ADMIN` на `/api/v1/admin/**`,
и методы контроллера дополнительно закрыты `@PreAuthorize`.
---
## 13. Стили и темы
Подключение через `src/styles/index.css`:
```
index.css → base.css → layout.css → sidebar.css → card.css → modal.css → ui.css
```
Дополнительно подключаются в компонентах: `chat.css` (`App.js`),
`reviews.css`, `rules.css`, `admin.css`, `NakhodkaLanding.css`.
CSS-переменные в `base.css`:
| Переменная | Значение | Описание |
|-----------|----------|----------|
| `--teal` | `#2de0c8` | Основной акцент |
| `--teal2` | `#1fc9b0` | Тёмный оттенок |
| `--border` | `rgba(255,255,255,0.09)` | Границы |
| `--muted` / `--muted2` | `rgba(255,255,255,.38)` / `.15` | Второстепенный текст |
| `--font` | `Nunito`, sans-serif | Шрифт |
| `--err` | `#ff6b6b` | Ошибка |
Тёмная тема — градиентные фоны, «стекло» (`backdrop-filter`), акцент `#2de0c8`;
светлая переключается через `ThemeContext`.
Адаптивность:
| Брейкпоинт | Что меняется |
|------------|-------------|
| `≤ 900px` | Правый сайдбар скрывается, навигация внизу |
| `≤ 768px` | Компактные отступы и кнопки |
| `≤ 600px` | Модалки на всю ширину |
---
## 14. Основные сценарии
**Аутентификация.** Гость нажимает «Разместить», «Мои объявления» или аватар →
открывается `AuthModal` → `POST /api/v1/auth/login` → `AuthContext.login()`
сохраняет `user`, `token`, `refreshToken` в `localStorage`. Просрочка access-токена
обрабатывается прозрачно: `apiFetch` при 401 обновляет токен и повторяет запрос;
при неудаче чистит сессию.
**Создание объявления.** Авторизованный пользователь → `CreateUpdatePost` →
заполняет форму → клиентское сжатие фото → `POST /api/v1/posts` (`FormData`) →
лента обновляется.
**Поиск и чат.** «Написать» → `POST /api/v1/chats/with/:otherUserId` →
переход на `/chats/:chatId` → подписка на `/topic/chat/{chatId}` →
сообщения в реальном времени.
**Карта.** `useYandexMap` ждёт `window.ymaps` (с таймаутом и ошибкой вместо
вечного спиннера), клик по карте ставит метку, drag метки и клик переводят
координаты в адрес через Яндекс.Геокодер; запрос на геокодинг защищён
request-id от гонок.
**Профиль.** Аватар в топбаре → `ProfileModal` → `PUT /api/v1/users/:id` →
данные обновляются в `AuthContext` и `localStorage`; «Выйти» — очистка сессии.
---
## 15. Известные проблемы и предупреждения
- **`fs.F_OK is deprecated`** при сборке — предупреждение Node 24 из внутренностей
`react-scripts@5`. Не влияет на результат; уходит при переходе на CRA 6 / Vite.
- **`npm audit`** показывает уязвимости в транзитивных зависимостях CRA
(webpack-dev-server и др.). Применять `npm audit fix --force` нельзя: это
ломающие обновления в пределах `react-scripts@5`.
- **Ключи Яндекса** попадали в историю git — их нужно перевыпустить.
- **Клиентская проверка роли** (`isAdmin`, `AdminRoute`) — только скрытие UI.
Реальная авторизация обязана быть на сервере по каждому эндпоинту.
- **Известная проблема бэкенда:** `GlobalExceptionHandler` ловит `Exception`
без исключений, поэтому битый JSON или неверный тип параметра дают `500`,
а не `400`. Из-за этого `type` и `status` в админских эндпоинтах принимаются
строками и разбираются вручную.

14
nginx.conf Normal file
View File

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

1464
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,14 @@
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@pbe/react-yandex-maps": "^1.2.5",
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.5",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"i18next": "^22.5.1", "i18next": "^22.5.1",
"lucide-react": "^1.11.0",
"postcss": "^8.5.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.74.0", "react-hook-form": "^7.74.0",
@@ -29,16 +21,16 @@
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sockjs-client": "^1.6.1", "sockjs-client": "^1.6.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"zod": "^4.3.6", "zod": "^4.3.6"
"zustand": "^5.0.12"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"test:ci": "react-scripts test --watchAll=false",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --max-warnings=0",
"typecheck": "tsc --noEmit",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
@@ -61,8 +53,11 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.18", "@types/react": "^18.3.0",
"@types/react-dom": "^19.2.7", "@types/react-dom": "^18.3.0",
"typescript": "^4.9.5" "typescript": "^5.9.3"
},
"overrides": {
"caniuse-lite": "^1.0.30001814"
} }
} }

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react_front</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,122 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App

View File

@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

126
src/api/adminContentApi.js Normal file
View File

@@ -0,0 +1,126 @@
// src/api/adminContentApi.js
//
// Админские эндпоинты для контента площадки (объявления). Здесь только то, что
// нельзя завернуть в общий хук: правка объявления идёт через usePostMutation
// с опцией adminMode — там же живёт сборка FormData и разбор ошибок модерации.
import { API_URL } from "../config/authConstants";
import { apiFetch } from "./apiClient";
const ADMIN_BASE = `${API_URL}/api/v1/admin`;
/**
* Ошибка админ-эндпоинтов контента (объявления). Несёт HTTP-статус,
* чтобы UI мог отличить 403 (нет роли ADMIN) от 404 и сетевых сбоев.
*/
export class AdminContentError extends Error {
constructor(status, message) {
super(message || `Admin content API error ${status}`);
this.name = "AdminContentError";
this.status = status;
}
}
async function readErrorMessage(res, fallback) {
try {
const body = await res.json();
if (body && typeof body.message === "string") return body.message;
if (body && typeof body.error === "string") return body.error;
} catch {
// тело не JSON (например, 204/пустой ответ) — используем fallback
}
return fallback;
}
/**
* Приводит объявление к тому виду, в котором его ждёт лента:
* type в нижний регистр ("loss"/"found"). Остальные хуки (useFeedPosts,
* useMyPosts) нормализуют так же — иначе карточка PostOnFeed отрисовалась бы
* с типом "находка" у потерянного объявления.
*/
function normalizePost(post) {
if (!post) return post;
return {
...post,
type: post.type ? String(post.type).toLowerCase() : "loss",
};
}
/** Достаёт элементы страницы: бэкенд отдаёт {content|last}, {items}, либо массив. */
function extractPage(body, size) {
if (Array.isArray(body)) {
return { items: body.map(normalizePost), last: body.length < size, total: body.length };
}
const rawItems = body?.content ?? body?.items ?? [];
const items = Array.isArray(rawItems) ? rawItems.map(normalizePost) : [];
const hasTotal = typeof body?.totalElements === "number";
const last = typeof body?.last === "boolean"
? body.last
// Без метаданных пагинации решаем по размеру страницы: недобор = это конец.
: hasTotal
? body.totalElements <= (body?.number ?? 0) + items.length
: items.length < size;
return { items, last, total: hasTotal ? body.totalElements : undefined };
}
/**
* GET /api/v1/admin/posts?page&size&search&type&status
*
* Постранично отдаёт ВСЕ объявления площадки вместе с данными автора.
* Возвращает { items, last, total } — items уже нормализованы.
*/
export async function getAdminPosts({ page = 0, size = 20, search = "", type = "", status = "" } = {}) {
const params = new URLSearchParams({ page, size });
if (search) params.set("search", search);
if (type) params.set("type", type);
if (status) params.set("status", status);
const res = await apiFetch(`${ADMIN_BASE}/posts?${params.toString()}`);
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to load posts"));
return extractPage(await res.json(), size);
}
/**
* GET /api/v1/admin/posts/rejected?page&size&search&type&status
*
* Постранично отдаёт объявления, не прошедшие модерацию (REJECTED),
* вместе с данными автора.
* Возвращает { items, last, total } — items уже нормализованы.
*/
export async function getAdminRejectedPosts({ page = 0, size = 20, search = "", type = "", status = "" } = {}) {
const params = new URLSearchParams({ page, size });
if (search) params.set("search", search);
if (type) params.set("type", type);
if (status) params.set("status", status);
const res = await apiFetch(`${ADMIN_BASE}/posts/rejected?${params.toString()}`);
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to load rejected posts"));
return extractPage(await res.json(), size);
}
/** DELETE /api/v1/admin/posts/{postId} — 204 и 200 одинаково допустимы. */
export async function deleteAdminPost(postId) {
const res = await apiFetch(`${ADMIN_BASE}/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
});
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to delete post"));
return true;
}
/**
* PATCH /api/v1/admin/posts/{postId}/status — "найдено" (CLOSED)
* либо возврат объявления в ленту (ACTIVE).
*/
export async function updateAdminPostStatus(postId, status) {
const res = await apiFetch(`${ADMIN_BASE}/posts/${encodeURIComponent(postId)}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new AdminContentError(res.status, await readErrorMessage(res, "Failed to update status"));
try {
return normalizePost(await res.json());
} catch {
// Бэкенд может ответить без тела — тогда мержим только статус.
return { id: postId, status };
}
}

View File

@@ -63,30 +63,3 @@ export function putWithProgress(url, file, onProgress) {
xhr.send(file); xhr.send(file);
}); });
} }
export async function uploadAdVideo(advertisingId, file, onProgress) {
const presignRes = await apiFetch(
`${API_URL}/api/v1/admin/ads/${advertisingId}/video/presign-upload`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type }),
}
);
if (!presignRes.ok) throw new Error(`Presign failed (${presignRes.status})`);
const { uploadUrl, sourceKey } = await presignRes.json();
await putWithProgress(uploadUrl, file, onProgress);
const confirmRes = await apiFetch(
`${API_URL}/api/v1/admin/ads/${advertisingId}/video/confirm-upload`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceKey }),
}
);
if (!confirmRes.ok) throw new Error(`Confirm failed (${confirmRes.status})`);
return { sourceKey };
}

View File

@@ -106,7 +106,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
<p style={{textAlign: "center", marginTop: 14, color: "var(--muted)", fontSize: ".85rem"}}> <p style={{textAlign: "center", marginTop: 14, color: "var(--muted)", fontSize: ".85rem"}}>
{t('auth.noAccount')}{" "} {t('auth.noAccount')}{" "}
<button onClick={onSwitch} <button type="button" onClick={onSwitch}
style={{background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}} style={{background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}}
> >
{t('auth.register')} {t('auth.register')}
@@ -224,7 +224,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
<p style={{textAlign: "center", marginTop: 14, color: "var(--muted)", fontSize: ".85rem"}}> <p style={{textAlign: "center", marginTop: 14, color: "var(--muted)", fontSize: ".85rem"}}>
{t('auth.hasAccount')}{" "} {t('auth.hasAccount')}{" "}
<button onClick={onSwitch} <button type="button" onClick={onSwitch}
style={{background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}}> style={{background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontWeight: 700, fontSize: ".85rem", fontFamily: "var(--font)",}}>
{t('auth.loginLink')} {t('auth.loginLink')}
</button> </button>

View File

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

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_URL, getImageUrl } from "../config/authConstants"; import { API_URL, getImageUrl } from "../config/authConstants";
import { useGeolocation, GEO_STATUS } from "../hooks/useGeolocation"; import { useGeolocation, GEO_STATUS } from "../hooks/useGeolocation";
import { whenYmapsReady } from "../utils/ymapsApi";
const MARKER_COLORS = { const MARKER_COLORS = {
LOST: "#ff3b30", LOST: "#ff3b30",
@@ -40,12 +41,93 @@ function createMarkerLayout(color, imageUrl) {
const FOCUS_ZOOM = 16; const FOCUS_ZOOM = 16;
const COORD_MATCH_TOLERANCE = 0.005; const COORD_MATCH_TOLERANCE = 0.005;
const MAP_CENTER = [55.751574, 37.573856];
const MAP_ZOOM = 11;
const BOUNDS_DEBOUNCE_MS = 400;
const BOUNDS_RETRY_MS = 120;
const MAX_BOUNDS_ATTEMPTS = 25;
export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, onBack }) { /**
* GET /api/v1/posts/map
*
* Контракт требует границы видимой области карты — без любого из четырёх
* параметров бэкенд отвечает 400. type/category остаются опциональными и
* уезжают тем же запросом.
*/
async function loadMarkers(bounds, type, category) {
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
const params = new URLSearchParams({
minLat: minLat.toString(),
maxLat: maxLat.toString(),
minLng: minLng.toString(),
maxLng: maxLng.toString(),
});
if (type) params.append("type", type);
if (category) params.append("category", category);
const response = await fetch(`${API_URL}/api/v1/posts/map?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load markers: ${response.status}`);
}
return response.json();
}
// Ключ для отсечения повторных запросов с одними и теми же границами.
// Нужен, потому что границы меняются и от действий пользователя (pan/zoom),
// и от программной установки центра — иначе на каждое такое изменение
// улетал бы лишний одинаковый запрос.
function boundsKey(bounds) {
return bounds.map(([lat, lng]) => `${lat.toFixed(5)}:${lng.toFixed(5)}`).join("|");
}
function createPostPlacemark(m) {
const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST;
const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря";
const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null;
const iconLayout = createMarkerLayout(color, imgSrc);
return new window.ymaps.Placemark(
[m.latitude, m.longitude],
{},
{
iconLayout,
iconImageSize: [40, 40],
iconImageOffset: [-20, -20],
// Без явного iconShape карта не может корректно вычислить
// кликабельную область для кастомного HTML-лейаута —
// маркер рисуется, но клики по нему не срабатывают.
iconShape: {
type: "Circle",
coordinates: [20, 20],
radius: 20,
},
hasBalloon: false,
hasHint: true,
hintContent: m.title || typeLabel,
}
);
}
export default function MapView({
focusPostId,
focusLat,
focusLng,
type = null,
category = null,
onPostClick,
onBack,
}) {
const { t } = useTranslation(); const { t } = useTranslation();
// tRef нужен, чтобы читать перевод из эффектов, не добавляя t в их deps:
// иначе смена языка пересоздавала бы карту и все маркеры с нуля.
const tRef = useRef(t);
tRef.current = t;
const mapRef = useRef(null); const mapRef = useRef(null);
const ymapRef = useRef(null); const ymapRef = useRef(null);
const userMarkerRef = useRef(null); const userMarkerRef = useRef(null);
const markersRef = useRef(null);
const renderMarkersRef = useRef(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -54,119 +136,248 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
const onPostClickRef = useRef(onPostClick); const onPostClickRef = useRef(onPostClick);
onPostClickRef.current = onPostClick; onPostClickRef.current = onPostClick;
// Фильтры читаются через ref: их смена не должна пересоздавать карту,
// они просто уезжают вместе с границами в том же самом запросе.
const filtersRef = useRef({ type, category });
filtersRef.current = { type, category };
useEffect(() => { useEffect(() => {
let map; let map;
const destroyed = { current: false }; let destroyed = false;
let debounceTimer = null;
let boundsRetryTimer = null;
let firstLoadDone = false;
let lastBoundsKey = null;
let requestId = 0;
const subscriptions = [];
const init = async () => { // Перерисовывает маркеры в текущей видимой области и возвращает
if (!mapRef.current || destroyed.current) return; // словарь postId -> [lat, lng] для логики фокуса.
const renderMarkers = (markers) => {
const collection = markersRef.current;
const placemarksById = new Map();
if (!collection) return placemarksById;
collection.removeAll();
markers.forEach((m) => {
if (m.latitude == null || m.longitude == null) return;
const placemark = createPostPlacemark(m);
collection.add(placemark);
if (m.id != null) {
const postId = String(m.id);
placemarksById.set(postId, [m.latitude, m.longitude]);
placemark.events.add("click", () => {
if (onPostClickRef.current) {
onPostClickRef.current(postId);
}
});
}
});
return placemarksById;
};
renderMarkersRef.current = renderMarkers;
const focusOnPost = (placemarksById) => {
let target = focusPostId != null
? placemarksById.get(String(focusPostId))
: null;
if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) {
let best = null;
let bestDist = Infinity;
placemarksById.forEach((coords) => {
const d =
Math.pow(coords[0] - focusLat, 2) +
Math.pow(coords[1] - focusLng, 2);
if (d < bestDist) {
bestDist = d;
best = coords;
}
});
if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) {
target = best;
}
}
if (target) {
map.setCenter(target, FOCUS_ZOOM);
}
};
const load = async (bounds, isInitial) => {
if (isInitial) setLoading(true);
const request = ++requestId;
try { try {
const res = await fetch(`${API_URL}/api/v1/posts/map`); const markers = await loadMarkers(
if (!res.ok) throw new Error("Failed to fetch map markers"); bounds,
const markers = await res.json(); filtersRef.current.type,
filtersRef.current.category
);
if (destroyed || request !== requestId) return;
if (destroyed.current) return; const placemarksById = renderMarkers(Array.isArray(markers) ? markers : []);
if (isInitial) focusOnPost(placemarksById);
} catch (e) {
// Фоновая догрузка не должна перекрывать рабочую карту ошибкой —
// показываем её только на первом запросе.
if (!destroyed && request === requestId && isInitial) setError(e.message);
} finally {
if (!destroyed && request === requestId) setLoading(false);
// Спиннер снимаем в любом случае: иначе при пустых границах карта
// навсегда останется закрыта оверлеем загрузки.
if (!destroyed && isInitial) {
firstLoadDone = true;
setLoading(false);
}
}
};
const requestMarkers = (bounds, isInitial) => {
if (destroyed) return;
if (!bounds || bounds.length < 2) return;
const key = boundsKey(bounds);
if (key === lastBoundsKey) return;
lastBoundsKey = key;
load(bounds, isInitial);
};
// Перемещение/зум: перезапрашиваем маркеры, но не на каждый пиксель.
const scheduleMarkers = () => {
if (destroyed) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
requestMarkers(readBounds(), false);
}, BOUNDS_DEBOUNCE_MS);
};
// Безопасное чтение границ: до полного рендера карты getBounds()
// возвращает null/undefined или координаты-заглушки.
const readBounds = () => {
if (destroyed || !map) return null;
const bounds = map.getBounds();
if (!bounds || bounds.length < 2) return null;
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
if (![minLat, minLng, maxLat, maxLng].every(Number.isFinite)) return null;
return bounds;
};
// Первая загрузка. У ymaps.Map в 2.1 нет события 'load', поэтому
// готовность границ отслеживаем через boundschange (он прилетает уже
// при первом расчёте вьюпорта) + короткий ретрай, если событие не пришло.
const loadInitial = (attempt = 0) => {
if (destroyed || firstLoadDone) return;
const bounds = readBounds();
if (bounds) {
requestMarkers(bounds, true);
return;
}
if (attempt >= MAX_BOUNDS_ATTEMPTS) {
firstLoadDone = true;
setLoading(false);
return;
}
boundsRetryTimer = setTimeout(() => loadInitial(attempt + 1), BOUNDS_RETRY_MS);
};
const init = () => {
if (!mapRef.current || destroyed) return;
try {
const hasFocusCoords = Number.isFinite(focusLat) && Number.isFinite(focusLng);
map = new window.ymaps.Map(mapRef.current, { map = new window.ymaps.Map(mapRef.current, {
center: [55.751574, 37.573856], center: hasFocusCoords ? [focusLat, focusLng] : MAP_CENTER,
zoom: 11, zoom: hasFocusCoords ? FOCUS_ZOOM : MAP_ZOOM,
controls: ["zoomControl", "geolocationControl"], controls: ["zoomControl", "geolocationControl"],
}); });
ymapRef.current = map; ymapRef.current = map;
const placemarksById = new Map(); const collection = new window.ymaps.GeoObjectCollection();
map.geoObjects.add(collection);
markersRef.current = collection;
markers.forEach((m) => { subscriptions.push(map.events.add("boundschange", () => {
if (m.latitude == null || m.longitude == null) return; if (!firstLoadDone) {
clearTimeout(boundsRetryTimer);
const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST; loadInitial();
const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря";
const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null;
const iconLayout = createMarkerLayout(color, imgSrc);
const coords = [m.latitude, m.longitude];
const placemark = new window.ymaps.Placemark(
coords,
{},
{
iconLayout,
iconImageSize: [40, 40],
iconImageOffset: [-20, -20],
// Без явного iconShape карта не может корректно вычислить
// кликабельную область для кастомного HTML-лейаута —
// маркер рисуется, но клики по нему не срабатывают.
iconShape: {
type: "Circle",
coordinates: [20, 20],
radius: 20,
},
hasBalloon: false,
hasHint: true,
hintContent: m.title || typeLabel,
}
);
map.geoObjects.add(placemark);
if (m.id != null) {
const postId = String(m.id);
placemarksById.set(postId, { placemark, coords });
placemark.events.add("click", () => {
if (onPostClickRef.current) {
onPostClickRef.current(postId);
}
});
} }
}); scheduleMarkers();
}));
let target = focusPostId != null // Подстраховка на случай, если boundschange не придёт.
? placemarksById.get(String(focusPostId)) loadInitial();
: null;
if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) {
let best = null;
let bestDist = Infinity;
placemarksById.forEach((entry) => {
const d =
Math.pow(entry.coords[0] - focusLat, 2) +
Math.pow(entry.coords[1] - focusLng, 2);
if (d < bestDist) {
bestDist = d;
best = entry;
}
});
if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) {
target = best;
}
}
if (target) {
map.setCenter(target.coords, FOCUS_ZOOM);
} else if (placemarksById.size > 0) {
map.setBounds(
map.geoObjects.getBounds(),
{ checkZoomRange: true, zoomMargin: 40 }
);
}
} catch (e) { } catch (e) {
if (!destroyed.current) setError(e.message); if (!destroyed) {
} finally { setError(e.message);
if (!destroyed.current) setLoading(false); setLoading(false);
}
} }
}; };
if (window.ymaps?.ready) { whenYmapsReady()
window.ymaps.ready(init); .then((ymaps) => {
} if (destroyed) return;
ymaps.ready(init);
})
.catch((e) => {
// API карт не подгрузился (заблокирован/офлайн/медленный CDN).
// Без этой ветки пользователь смотрел бы в вечный спиннер без ошибки.
if (!destroyed) {
setError(tRef.current("post.mapLoadFailed"));
setLoading(false);
}
console.warn("Yandex Maps API failed to load:", e);
});
return () => { return () => {
destroyed.current = true; destroyed = true;
clearTimeout(debounceTimer);
clearTimeout(boundsRetryTimer);
subscriptions.forEach((sub) => {
try {
map?.events.remove(sub);
} catch (e) {
// карта уже уничтожена — ничего не делаем
}
});
ymapRef.current?.destroy(); ymapRef.current?.destroy();
ymapRef.current = null; ymapRef.current = null;
markersRef.current = null;
renderMarkersRef.current = null;
userMarkerRef.current = null; userMarkerRef.current = null;
}; };
}, [focusPostId, focusLat, focusLng]); }, [focusPostId, focusLat, focusLng]);
// Смена фильтров type/category: догружаем маркеры для текущих границ,
// карту при этом не пересоздаём.
useEffect(() => {
if (type == null && category == null) return;
const map = ymapRef.current;
if (!map) return;
const bounds = map.getBounds();
if (!bounds || bounds.length < 2) return;
const timer = setTimeout(async () => {
try {
const markers = await loadMarkers(bounds, type, category);
renderMarkersRef.current?.(Array.isArray(markers) ? markers : []);
} catch (e) {
// Фоновый запрос: молча оставляем прежние маркеры на месте.
}
}, BOUNDS_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [type, category]);
// Геолокация: если юзер разрешил и карта уже создана — центр на его месте. // Геолокация: если юзер разрешил и карта уже создана — центр на его месте.
// Не трогаем, если карта была открыта с фокусом на конкретном объявлении. // Не трогаем, если карта была открыта с фокусом на конкретном объявлении.
const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng)); const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng));
@@ -195,7 +406,7 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
return ( return (
<div className="f-mapview"> <div className="f-mapview">
{loading && ( {loading && (
<div className="f-mapview-status"> <div className="f-mapview-status" data-testid="mapview-loading">
<div className="f-spinner" /> <div className="f-spinner" />
</div> </div>
)} )}
@@ -228,4 +439,4 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
)} )}
</div> </div>
); );
} }

View File

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

View File

@@ -123,7 +123,7 @@ export default function Sidebar({
</button> </button>
<button <button
type="button" type="button"
className="f-sb-item" className={`f-sb-item${activeNav === "admin-campaigns" ? " active" : ""}`}
onClick={() => { onClick={() => {
navigate("/admin/ads"); navigate("/admin/ads");
onClose(); onClose();
@@ -131,6 +131,26 @@ export default function Sidebar({
> >
{NavIcons.grid()} {t('adminPanel.campaignsNav')} {NavIcons.grid()} {t('adminPanel.campaignsNav')}
</button> </button>
<button
type="button"
className={`f-sb-item${activeNav === "admin-posts" ? " active" : ""}`}
onClick={() => {
navigate("/admin/ads?tab=posts");
onClose();
}}
>
{NavIcons.rules()} {t('adminPanel.postsNav')}
</button>
<button
type="button"
className={`f-sb-item${activeNav === "admin-posts-rejected" ? " active" : ""}`}
onClick={() => {
navigate("/admin/ads?tab=rejected");
onClose();
}}
>
{NavIcons.rules()} {t('adminPanel.postsRejectedNav')}
</button>
</> </>
)} )}
</nav> </nav>

22
src/components/Toast.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* Типы для Toast.
*
* Почему отдельный .d.ts, а не JSDoc в Toast.jsx: у функции с деструктурированным
* параметром TS сворачивает тип в `string` (JSDoc-теги применяются к самому
* объекту параметра, а не к его полям). Из-за этого любой .tsx-файл, рендерящий
* <Toast .../>, падал с TS2322. Объявление отделяет контракт от реализации.
*/
type ToastType = "error" | "success";
interface ToastProps {
/** Текст сообщения. Пустая строка/null — тост не рендерится. */
message: string | null | undefined;
type?: ToastType;
/** Вызывается по истечении duration и по клику на крестик. */
onDismiss?: () => void;
duration?: number;
}
declare function Toast(props: ToastProps): JSX.Element | null;
export default Toast;

View File

@@ -1,5 +1,5 @@
// src/components/Toast.jsx // src/components/Toast.jsx
import { useEffect } from "react"; import { useEffect, useRef } from "react";
/** /**
* Toast — всплывающее сообщение об ошибке/успехе, автоматически скрывается. * Toast — всплывающее сообщение об ошибке/успехе, автоматически скрывается.
@@ -10,10 +10,16 @@ import { useEffect } from "react";
* @param {number} [duration=4000] — время жизни в мс * @param {number} [duration=4000] — время жизни в мс
*/ */
export default function Toast({ message, type = "error", onDismiss, duration = 4000 }) { export default function Toast({ message, type = "error", onDismiss, duration = 4000 }) {
// onDismiss почти всегда — свежий инлайн-стрелки. Если держать его в
// зависимостях useEffect, любой ре-рендер родителя (сообщения чата, поллинг
// рекламы) перезапускал бы отсчёт и тост висел бы бесконечно.
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => { useEffect(() => {
const timer = setTimeout(onDismiss, duration); const timer = setTimeout(() => dismissRef.current?.(), duration);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [onDismiss, duration]); }, [duration]);
if (!message) return null; if (!message) return null;

View File

@@ -0,0 +1,276 @@
// src/components/admin/PostsTab.jsx
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import PostOnFeed from "../../pages/PostOnFeed";
import CreateUpdatePost from "../../pages/CreateUpdatePost";
import ImageViewer from "../ImageViewer";
import Toast from "../Toast";
import {
getAdminPosts,
getAdminRejectedPosts,
deleteAdminPost,
updateAdminPostStatus,
} from "../../api/adminContentApi";
const PAGE_SIZE = 20;
const SEARCH_DEBOUNCE_MS = 400;
const TYPE_FILTERS = [
{ value: "", labelKey: "adminPanel.filterTypeAll" },
{ value: "LOSS", labelKey: "postCard.loss" },
{ value: "FOUND", labelKey: "postCard.found" },
];
const STATUS_FILTERS = [
{ value: "", labelKey: "adminPanel.filterStatusAll" },
{ value: "ACTIVE", labelKey: "adminPanel.statusActive" },
{ value: "CLOSED", labelKey: "adminPanel.statusClosed" },
];
/** Подпись автора объявления: у бэкенда поле называется по-разному в разных DTO. */
function authorLabelOf(post) {
const name = post.ownerName || post.authorName || post.userName ||
[post.ownerFirstName, post.ownerLastName].filter(Boolean).join(" ");
const login = post.ownerLogin || post.authorLogin || post.userLogin;
const parts = [name, login ? `@${login}` : null].filter(Boolean);
return parts.length ? parts.join(" · ") : null;
}
/**
* Вкладка «Все объявления» админ-панели.
*
* Показывает ВСЕ объявления площадки с тем же набором действий, что и вкладка
* «Мои объявления» у обычного пользователя (редактирование, удаление, отметка
* «найдено»), плюс возможность вернуть закрытое объявление обратно в ленту.
*
* Все мутации идут через /api/v1/admin/posts — сервер проверяет роль ADMIN
* независимо от того, что интерфейс вообще показал админские кнопки.
*
* Разметка рассчитана на то, что страница уже нарисовала .admp > .admp-inner,
* поэтому собственного скролл-контейнера у вкладки нет.
*/
export default function PostsTab({ isAdmin, mode = "all" }) {
const { t } = useTranslation();
const isRejectedMode = mode === "rejected";
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [editing, setEditing] = useState(null);
const [viewer, setViewer] = useState(null);
const [busyId, setBusyId] = useState(null);
const [toast, setToast] = useState(null);
const showToast = useCallback((message, type = "success") => {
setToast({ message, type });
}, []);
// Дебаунс поиска, чтобы не дёргать сервер на каждое нажатие клавиши
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchInput]);
// Смена фильтра сбрасывает пагинацию, поэтому load всегда стартует с 0-й страницы.
const load = useCallback(async ({ pageNum = 0, append = false, silent = false } = {}) => {
if (!isAdmin) return;
if (append) setLoadingMore(true);
else if (!silent) setLoading(true);
setError("");
try {
const fetcher = isRejectedMode ? getAdminRejectedPosts : getAdminPosts;
const result = await fetcher({ page: pageNum, size: PAGE_SIZE, search, type: typeFilter, status: statusFilter });
setPosts((prev) => (append ? [...prev, ...result.items] : result.items));
setHasMore(!result.last);
setPage(pageNum);
} catch {
setError(t("adminPanel.postsLoadError"));
if (!append) setPosts([]);
setHasMore(false);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [isAdmin, search, typeFilter, statusFilter, t, isRejectedMode]);
// Первичная загрузка + перезагрузка при смене фильтров
useEffect(() => {
load({ pageNum: 0 });
}, [load]);
const patchPost = useCallback((postId, patch) => {
setPosts((prev) => prev.map((p) => (p.id === postId ? { ...p, ...patch } : p)));
}, []);
const handleDelete = useCallback(async (postId) => {
if (!window.confirm(t("adminPanel.postsConfirmDelete"))) return;
setBusyId(postId);
try {
await deleteAdminPost(postId);
setPosts((prev) => prev.filter((p) => p.id !== postId));
showToast(t("adminPanel.postsDeleted"));
} catch {
showToast(t("adminPanel.postsDeleteFailed"), "error");
} finally {
setBusyId(null);
}
}, [t, showToast]);
const setStatus = useCallback(async (postId, status) => {
const isClose = status === "CLOSED";
if (!window.confirm(isClose ? t("adminPanel.postsConfirmClose") : t("adminPanel.postsConfirmReopen"))) return;
setBusyId(postId);
try {
const updated = await updateAdminPostStatus(postId, status);
// Сервер может вернуть неполный DTO — мержим, чтобы не потерять поля карточки.
patchPost(postId, { ...(updated || {}), id: postId, status });
showToast(isClose ? t("adminPanel.postsClosed") : t("adminPanel.postsReopened"));
} catch {
showToast(t("adminPanel.postsStatusFailed"), "error");
} finally {
setBusyId(null);
}
}, [t, showToast, patchPost]);
const handleEditSaved = useCallback(() => {
// Перезагружаем текущую страницу: сервер мог изменить статус или дату.
load({ pageNum: page, silent: true });
showToast(t("adminPanel.postsUpdated"));
}, [load, page, showToast, t]);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) setViewer({ images, index, ad });
}, []);
const contentStyle = useMemo(() => ({ opacity: busyId === null ? 1 : 0.75 }), [busyId]);
if (!isAdmin) return null;
return (
<>
<div className="admp-toolbar admp-toolbar--filters">
<input
type="search"
className="f-m-field admp-search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder={t("adminPanel.postsSearchPlaceholder")}
aria-label={t("adminPanel.postsSearchPlaceholder")}
/>
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsTypeFilter")}>
{TYPE_FILTERS.map(({ value, labelKey }) => (
<button
key={value || "all"}
type="button"
className={`admp-chip${typeFilter === value ? " active" : ""}`}
onClick={() => setTypeFilter(value)}
>
{t(labelKey)}
</button>
))}
</div>
<div className="admp-filter-group" role="group" aria-label={t("adminPanel.postsStatusFilter")}>
{STATUS_FILTERS.map(({ value, labelKey }) => (
<button
key={value || "all"}
type="button"
className={`admp-chip${statusFilter === value ? " active" : ""}`}
onClick={() => setStatusFilter(value)}
>
{t(labelKey)}
</button>
))}
</div>
<button
type="button"
className="admp-btn admp-btn--ghost"
onClick={() => load({ pageNum: 0 })}
disabled={loading}
>
{t("adminPanel.refresh")}
</button>
</div>
<div className="admp-session-hint">
{loading
? t("adminPanel.postsLoading")
: t("adminPanel.postsHint", { count: posts.length })}
</div>
{error && <div className="f-err-msg admp-load-error">{error}</div>}
{loading && (
<div className="f-center admp-empty">
<div className="f-spinner" />
</div>
)}
{!loading && posts.length === 0 && (
<div className="f-center admp-empty">
<div className="f-center-icon">📋</div>
<div className="f-center-title">{t("adminPanel.postsEmpty")}</div>
<div className="f-center-sub">{t("adminPanel.postsEmptySub")}</div>
</div>
)}
<div className="admp-posts-grid" style={contentStyle}>
{posts.map((post) => (
<PostOnFeed
key={post.id}
ad={post}
isOwner
allowEditClosed
authorLabel={authorLabelOf(post)}
onEdit={setEditing}
onDelete={handleDelete}
onMarkFound={(id) => setStatus(id, "CLOSED")}
onReopen={(id) => setStatus(id, "ACTIVE")}
onImageClick={handleImageClick}
/>
))}
</div>
{!loading && hasMore && (
<div className="admp-more">
<button
type="button"
className="admp-btn admp-btn--ghost"
disabled={loadingMore}
onClick={() => load({ pageNum: page + 1, append: true })}
>
{loadingMore ? t("adminPanel.postsLoading") : t("adminPanel.loadMore")}
</button>
</div>
)}
{editing && (
<CreateUpdatePost
adminMode
initialData={editing}
onClose={() => setEditing(null)}
onSuccess={handleEditSaved}
/>
)}
{viewer && (
<ImageViewer
images={viewer.images}
initialIndex={viewer.index}
postData={viewer.ad}
onClose={() => setViewer(null)}
/>
)}
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
</>
);
}

View File

@@ -1,28 +0,0 @@
import { useEffect, useState } from "react";
/**
* Отдельный блюр-слой поверх ленты, независим от FeedTopBar.
* Прижат к верху общего контейнера, высота подстраивается под
* реальную высоту topBar (включая safe-area на iOS).
*/
export default function FeedBlurMask({ topBarRef }) {
const [height, setHeight] = useState(56);
useEffect(() => {
const el = topBarRef?.current;
if (!el) return;
const update = () => setHeight(el.getBoundingClientRect().height || 56);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [topBarRef]);
return (
<div
className="f-feed-blur-mask"
style={{ height }}
aria-hidden="true"
/>
);
}

View File

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

View File

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

View File

@@ -35,6 +35,9 @@ export default function PullToRefresh({ containerRef, onRefresh, disabled = fals
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
el.classList.toggle("f-ptr-active", visible); el.classList.toggle("f-ptr-active", visible);
// Размонтирование с visible=true иначе навсегда оставило бы маску
// затухания и блокировку pointer-events на контейнере ленты.
return () => el.classList.remove("f-ptr-active");
}, [visible, containerRef]); }, [visible, containerRef]);
useEffect(() => { useEffect(() => {

View File

@@ -1,21 +0,0 @@
export default function TypeFilterBar({ value, onChange, t }) {
const options = [
{ key: "loss", label: t('feed.filterLoss') },
{ key: null, label: t('feed.filterAll') },
{ key: "found", label: t('feed.filterFound') },
];
return (
<div className="f-filter-bar">
{options.map(opt => (
<button
key={opt.key ?? "all"}
className={`f-filter-btn${value === opt.key ? " active" : ""}`}
onClick={() => onChange(opt.key)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

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

View File

@@ -1,10 +0,0 @@
export default function HeartIcon({filled}) {
return (
<svg width={14} height={14} viewBox="0 0 24 24"
fill={filled ? "#ff6b6b" : "none"} stroke="#ff6b6b" strokeWidth={2}
strokeLinecap="round">
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
);
}

View File

@@ -1,13 +0,0 @@
import magnifier from "../../assets/magnifier.png";
export default function LogoIcon() {
return (
<img
src={magnifier}
alt="logo"
width={20}
height={20}
style={{ objectFit: "contain" }}
/>
);
}

View File

@@ -1,10 +0,0 @@
export default function MapIcon() {
return (
<svg width={14} height={14} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
<line x1="8" y1="2" x2="8" y2="18"/>
<line x1="16" y1="6" x2="16" y2="22"/>
</svg>
);
}

View File

@@ -12,10 +12,24 @@ export const NAV_BOTTOM = [
{ icon: "rules", key: "rules", labelKey: "nav.rules" }, { icon: "rules", key: "rules", labelKey: "nav.rules" },
]; ];
export const CATEGORY_KEYS = [ /**
"categories.item", "categories.pet", "categories.other", * Категории объявления.
*
* value — то, что уходит на бэкенд и хранится в БД (в т.ч. у уже созданных
* объявлений), поэтому это стабильные значения, а НЕ переводы. Раньше сюда
* клали i18n-ключи и отправляли t(key), из-за чего одно и то же объявление
* получало разный category в зависимости от языка интерфейса.
*/
export const CATEGORIES = [
{ value: "Вещь", labelKey: "categories.item" },
{ value: "Питомец", labelKey: "categories.pet" },
{ value: "Другое", labelKey: "categories.other" },
]; ];
export const CATEGORY_ITEM = "Вещь";
export const CATEGORY_PET = "Питомец";
export const CATEGORY_OTHER = "Другое";
export const EMPTY_FORM = { export const EMPTY_FORM = {
type: "loss", type: "loss",
title: "", title: "",
@@ -29,5 +43,3 @@ export const EMPTY_FORM = {
reward: false, reward: false,
rewardText: "" rewardText: ""
}; };
export const MAX_PHOTO_SIZE_MB = 5;

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useRef } from "react";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient"; import { apiFetch } from "../api/apiClient";
@@ -11,8 +11,12 @@ export function useChats() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const { updateFromChats, clearChatUnread } = useUnread(); const { updateFromChats, clearChatUnread } = useUnread();
// fetchChats дёргается и при монтировании, и на каждое изменение totalUnread —
// без guard'а медленный предыдущий ответ воскрешал бы прочитанные счётчики.
const requestIdRef = useRef(0);
const fetchChats = useCallback(async () => { const fetchChats = useCallback(async () => {
const requestId = ++requestIdRef.current;
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
@@ -20,14 +24,16 @@ export function useChats() {
if (!res.ok) throw new Error(t('chats.fetchError')); if (!res.ok) throw new Error(t('chats.fetchError'));
const data = await res.json(); const data = await res.json();
if (requestId !== requestIdRef.current) return; // устаревший ответ
const list = Array.isArray(data) ? data : (data.items ?? data.content ?? []); const list = Array.isArray(data) ? data : (data.items ?? data.content ?? []);
cachePostsFromChats(list); cachePostsFromChats(list);
setChats(list); setChats(list);
updateFromChats(list); updateFromChats(list);
} catch (e) { } catch (e) {
if (requestId !== requestIdRef.current) return;
setError(e.message || t('chats.fetchError')); setError(e.message || t('chats.fetchError'));
} finally { } finally {
setLoading(false); if (requestId === requestIdRef.current) setLoading(false);
} }
}, [updateFromChats, t]); }, [updateFromChats, t]);
@@ -46,6 +52,9 @@ export function useChats() {
const fetchMessages = useCallback(async (chatId) => { const fetchMessages = useCallback(async (chatId) => {
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}/messages`); const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}/messages`);
// Без этой проверки 5xx с JSON-телом ошибки превращался в пустой список,
// и только что отправленное сообщение выглядело как потерянное.
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : (data.items ?? data.content ?? []); return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
}, []); }, []);

View File

@@ -33,14 +33,14 @@ export function useDeleteChat() {
if (!res.ok) { if (!res.ok) {
let body; let body;
try { body = await res.json(); } catch { body = null; } try { body = await res.json(); } catch { body = null; }
throw new Error(body?.message || t('chat.deleteErrorWithStatus', { status: res.status })); throw new Error(body?.message || t('chats.deleteErrorWithStatus', { status: res.status }));
} }
clearChatUnread(chatId); clearChatUnread(chatId);
removePostForChat(chatId); removePostForChat(chatId);
return { success: true, error: null }; return { success: true, error: null };
} catch (err) { } catch (err) {
const message = err.message || t('chat.deleteError'); const message = err.message || t('chats.deleteError');
setError(message); setError(message);
return { success: false, error: message }; return { success: false, error: message };
} finally { } finally {

View File

@@ -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("");
@@ -33,6 +34,10 @@ export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthent
}, [refresh, loadMyPosts]); }, [refresh, loadMyPosts]);
const handleDelete = useCallback(async (postId) => { const handleDelete = useCallback(async (postId) => {
if (!isAuthenticated) {
onAuthRequired?.();
return;
}
if (!window.confirm(t('feed.confirmDelete'))) return; if (!window.confirm(t('feed.confirmDelete'))) return;
const result = await deletePost(postId); const result = await deletePost(postId);
if (result.success) { if (result.success) {
@@ -40,23 +45,47 @@ export function useFeedActions({ refresh, loadMyPosts, onAuthRequired, isAuthent
} else { } else {
setError(result.error); setError(result.error);
} }
}, [t, deletePost, afterMutation]); }, [t, deletePost, afterMutation, isAuthenticated, onAuthRequired]);
const handleMarkFound = useCallback(async (postId) => { const handleMarkFound = useCallback(async (postId) => {
if (!isAuthenticated) {
onAuthRequired?.();
return;
}
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]); }, [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,

View File

@@ -1,8 +1,21 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useRef } from "react";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
/**
* Достаёт элементы страницы независимо от формы ответа бэкенда
* (Spring Page {content,last}, {items,last} или голый массив).
*/
function extractPage(data) {
if (Array.isArray(data)) {
return { items: data, last: data.length < PAGE_SIZE };
}
const items = data?.content ?? data?.items ?? [];
const last = typeof data?.last === "boolean" ? data.last : items.length < PAGE_SIZE;
return { items, last };
}
/** /**
* Управляет основной лентой объявлений: загрузка, пагинация, поиск. * Управляет основной лентой объявлений: загрузка, пагинация, поиск.
*/ */
@@ -13,8 +26,13 @@ export function useFeedPosts() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Номер последнего начатого запроса: ответы приходят не по порядку
// (дебаунс поиска, бесконечный скролл, refresh после удаления), и без
// этой проверки медленный «старый» ответ перетирал бы свежий.
const requestIdRef = useRef(0);
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => { const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
const requestId = ++requestIdRef.current;
if (pageNum === 0) { if (pageNum === 0) {
setLoading(true); setLoading(true);
} else { } else {
@@ -28,21 +46,37 @@ export function useFeedPosts() {
const res = await fetch(`${API_URL}/api/v1/posts?${params}`); const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Error ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json(); const data = await res.json();
if (requestId !== requestIdRef.current) return; // устаревший ответ
const content = data.content ?? []; const { items, last } = extractPage(data);
const normalizedAds = content.map(ad => ({ const normalizedAds = items.map(ad => ({
...ad, ...ad,
type: ad.type ? ad.type.toLowerCase() : "loss", type: ad.type ? ad.type.toLowerCase() : "loss",
})); }));
setAds(prev => (append ? [...prev, ...normalizedAds] : normalizedAds)); setAds(prev => {
setHasMore(!data.last); const merged = append ? [...prev, ...normalizedAds] : normalizedAds;
// Пересекающиеся страницы (сдвиг при вставке новых объявлений) не должны
// давать дубликаты — React потеряет state у карточек с одинаковым key.
const seen = new Set();
return merged.filter(ad => {
if (seen.has(ad.id)) return false;
seen.add(ad.id);
return true;
});
});
setHasMore(!last);
setPage(pageNum); setPage(pageNum);
} catch (err) { } catch (err) {
if (requestId !== requestIdRef.current) return;
setError(err.message || "Failed to load ads"); setError(err.message || "Failed to load ads");
} finally { } finally {
setLoading(false); // Флаг снимает только «свой» запрос: иначе подгрузка страницы N
setLoadingMore(false); // гасила бы спиннер первой страницы, и наоборот.
if (requestId === requestIdRef.current) {
setLoading(false);
setLoadingMore(false);
}
} }
}, []); }, []);

View File

@@ -1,4 +1,4 @@
import { useRef, useCallback } from "react"; import { useRef, useCallback, useEffect } from "react";
/** /**
* Универсальный хук для бесконечного скролла через IntersectionObserver. * Универсальный хук для бесконечного скролла через IntersectionObserver.
@@ -9,19 +9,32 @@ import { useRef, useCallback } from "react";
*/ */
export function useInfiniteScroll(onIntersect, enabled) { export function useInfiniteScroll(onIntersect, enabled) {
const observerRef = useRef(null); const observerRef = useRef(null);
// Колбэк храним в ref: если держать его в зависимостях useCallback, то новый
// инлайн-колбэк на каждом рендере родителя пересоздавал бы IntersectionObserver,
// а новый observer сразу отдаёт первоначальное срабатывание — подгрузка
// страницы превращалась бы в цикл запросов.
const onIntersectRef = useRef(onIntersect);
onIntersectRef.current = onIntersect;
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const sentinelRef = useCallback(node => { const sentinelRef = useCallback((node) => {
if (!enabled) return; observerRef.current?.disconnect();
if (observerRef.current) observerRef.current.disconnect(); observerRef.current = null;
observerRef.current = new IntersectionObserver(entries => { if (!node || !enabledRef.current) return;
if (entries[0].isIntersecting && enabled) {
onIntersect(); observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && enabledRef.current) {
onIntersectRef.current();
} }
}, { rootMargin: "200px" }); }, { rootMargin: "200px" });
if (node) observerRef.current.observe(node); observerRef.current.observe(node);
}, [enabled, onIntersect]); }, []);
// Без этого observer остаётся подписан на уже отсоединённый узел.
useEffect(() => () => observerRef.current?.disconnect(), []);
return sentinelRef; return sentinelRef;
} }

View File

@@ -3,9 +3,8 @@ import { useTranslation } from 'react-i18next';
/** /**
* useLanguageToggle — переключение между ru/en. * useLanguageToggle — переключение между ru/en.
* *
* i18next сам сохраняет выбранный язык в localStorage * Persist делает сам i18n (см. src/i18n.js, подписка на languageChanged),
* через LanguageDetector (ключ 'i18nextLng'), * здесь только инициируем смену.
* если detection.caches включает localStorage.
*/ */
export function useLanguageToggle() { export function useLanguageToggle() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();

View File

@@ -11,15 +11,22 @@ import { apiFetch } from "../api/apiClient";
* id передан → PUT /api/v1/posts/:id (обновление) * id передан → PUT /api/v1/posts/:id (обновление)
* id не передан → POST /api/v1/posts (создание) * id не передан → POST /api/v1/posts (создание)
* *
* В режиме adminMode (правка чужого объявления из админ-панели) используется
* PUT /api/v1/admin/posts/:id. Создание через админку не поддерживается:
* приходит без id — падаем обратно на пользовательский POST.
*
* Авторизация (Authorization header + авто-обновление токена при истечении) * Авторизация (Authorization header + авто-обновление токена при истечении)
* обрабатывается внутри apiFetch. * обрабатывается внутри apiFetch.
* *
* save() возвращает: * save() возвращает:
* { data } — успешный ответ сервера * { ok: true, data } — успешный ответ сервера (data может быть null,
* { blocked: true } — контент заблокирован модерацией * если бэкенд ответил без тела)
* null — серверная ошибка (свойство error содержит текст) * { blocked: true } — контент заблокирован модерацией
* null — серверная ошибка (свойство error содержит текст)
*
* @param {{adminMode?: boolean}} [options]
*/ */
export function usePostMutation() { export function usePostMutation({ adminMode = false } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -27,13 +34,16 @@ export function usePostMutation() {
/** /**
* @param {string|null} id — id объявления при обновлении, null при создании * @param {string|null} id — id объявления при обновлении, null при создании
* @param {FormData} data — данные формы * @param {FormData} data — данные формы
* @returns {Promise<{data:object}|{blocked:boolean, reason:string}|null>} * @returns {Promise<{ok:true,data:object|null}|{blocked:boolean, reason:string}|null>}
*/ */
const save = async (id, data) => { const save = async (id, data) => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`; const isAdminEdit = adminMode && !!id;
const url = isAdminEdit
? `${API_URL}/api/v1/admin/posts/${id}`
: (id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`);
const method = id ? "PUT" : "POST"; const method = id ? "PUT" : "POST";
const res = await apiFetch(url, { const res = await apiFetch(url, {
method, method,
@@ -51,7 +61,9 @@ export function usePostMutation() {
throw new Error(body?.message || t('post.serverError', { status: res.status })); throw new Error(body?.message || t('post.serverError', { status: res.status }));
} }
return { data: await res.json() }; let payload = null;
try { payload = await res.json(); } catch { payload = null; }
return { ok: true, data: payload };
} catch (e) { } catch (e) {
setError(e.message || t('post.unknownError')); setError(e.message || t('post.unknownError'));
return null; return null;

View File

@@ -6,7 +6,7 @@ import { apiFetch } from '../api/apiClient';
/** /**
* Хук для изменения статуса поста (ACTIVE/CLOSED). * Хук для изменения статуса поста (ACTIVE/CLOSED).
* Используется для отметки "Вещь найдена" и (в будущем) для reopen. * Используется для отметки "найдено" и (в будущем) для reopen.
*/ */
export function usePostStatus() { export function usePostStatus() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -40,7 +40,7 @@ export function usePostStatus() {
}, [t]); }, [t]);
/** /**
* Изменяет статус поста на CLOSED (вещь найдена). * Изменяет статус поста на CLOSED («найдено»).
* @param {number} postId - ID поста * @param {number} postId - ID поста
* @returns {Promise<{success: boolean, error: string|null}>} * @returns {Promise<{success: boolean, error: string|null}>}
*/ */

View File

@@ -1,11 +1,15 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
/** /**
* Загрузка/отправка отзывов + производные данные (средний рейтинг, * Загрузка/отправка отзывов + производные данные (средний рейтинг,
* распределение по звёздам). * распределение по звёздам).
*
* token здесь намеренно не принимается: apiFetch берёт актуальный access-токен
* из хранилища сам и рефрешит его при 401.
*/ */
export function useReviews({ token, isAuthenticated, onAuthRequired, authorFallbackName }) { export function useReviews({ isAuthenticated, onAuthRequired, authorFallbackName }) {
const [reviews, setReviews] = useState([]); const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -37,12 +41,12 @@ export function useReviews({ token, isAuthenticated, onAuthRequired, authorFallb
setSubmitting(true); setSubmitting(true);
setSubmitError(""); setSubmitError("");
try { try {
const res = await fetch(`${API_URL}/api/v1/reviews`, { // apiFetch, а не голый fetch: он сам подставляет актуальный access-токен
// и обновляет его при 401. С пропом token из контекста запрос падал бы
// на просроченном токене (контекст обновляется только при логине).
const res = await apiFetch(`${API_URL}/api/v1/reviews`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ rating, text }), body: JSON.stringify({ rating, text }),
}); });
if (!res.ok) throw new Error(`Error ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
@@ -66,23 +70,28 @@ export function useReviews({ token, isAuthenticated, onAuthRequired, authorFallb
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [isAuthenticated, onAuthRequired, token, loadReviews, authorFallbackName]); }, [isAuthenticated, onAuthRequired, loadReviews, authorFallbackName]);
const avgRating = reviews.length // Несохранённые отзывы показываются пользователю, но не должны попадать
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1) // в публичную статистику площадки — иначе avgRating и распределение
// навсегда искажаются отзывами, которых нет на сервере.
const savedReviews = useMemo(() => reviews.filter((r) => !r.pending), [reviews]);
const avgRating = savedReviews.length
? (savedReviews.reduce((s, r) => s + r.rating, 0) / savedReviews.length).toFixed(1)
: null; : null;
const distribution = useMemo(() => { const distribution = useMemo(() => {
const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
reviews.forEach((r) => { savedReviews.forEach((r) => {
if (counts[r.rating] !== undefined) counts[r.rating] += 1; if (counts[r.rating] !== undefined) counts[r.rating] += 1;
}); });
return [5, 4, 3, 2, 1].map((star) => ({ return [5, 4, 3, 2, 1].map((star) => ({
star, star,
count: counts[star], count: counts[star],
pct: reviews.length ? Math.round((counts[star] / reviews.length) * 100) : 0, pct: savedReviews.length ? Math.round((counts[star] / savedReviews.length) * 100) : 0,
})); }));
}, [reviews]); }, [savedReviews]);
return { return {
reviews, loading, error, submitting, submitError, reviews, loading, error, submitting, submitError,

View File

@@ -1,71 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
export type ScrollDirection = "up" | "down";
/**
* useScrollDirection — отслеживает направление скролла контейнера `containerRef`
* и сообщает, когда тулбар должен быть скрыт.
*
* Логика (как в Telegram Web):
* - при скролле вниз тулбар прячется (hidden = true);
* - при скролле вверх (или подъезде в самое начало списка) — показывается;
* - есть порог `showThreshold` у вершины, чтобы тулбар появлялся не раньше,
* чем контент вернулся достаточно близко к началу.
*
* requestAnimationFrame + ref'ы: обработчик скролла НЕ триггерит ре-рендер
* на каждое событие — состояние меняется только когда скрытость реально
* пересекает границу, всё остальное живёт в ref'ах.
*/
export default function useScrollDirection<T extends HTMLElement>(
containerRef: RefObject<T | null>,
{ hideThreshold = 12, showThreshold = 88 }: { hideThreshold?: number; showThreshold?: number } = {},
): boolean {
const [hidden, setHidden] = useState(false);
const hiddenRef = useRef(false);
const lastYRef = useRef(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
lastYRef.current = el.scrollTop;
const onScroll = () => {
if (rafRef.current != null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const y = el.scrollTop;
const maxScroll = el.scrollHeight - el.clientHeight;
const isAtTop = y <= showThreshold;
const isAtBottom = maxScroll > 0 && y >= maxScroll - showThreshold;
// Без сенсорных/фрикционных возражений: крошечные рывки не считаем за жест
const dy = Math.abs(y - lastYRef.current);
let next = hiddenRef.current;
if (isAtTop || isAtBottom) {
next = false;
} else if (dy >= hideThreshold) {
next = y > lastYRef.current;
}
lastYRef.current = y;
if (next !== hiddenRef.current) {
hiddenRef.current = next;
setHidden(next);
}
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [containerRef, hideThreshold, showThreshold]);
return hidden;
}

View File

@@ -11,6 +11,11 @@ import { getAccessToken, refreshAccessToken } from "../api/apiClient";
* и переподключение. Конкретные топики и обработку сообщений задаёт вызывающий * и переподключение. Конкретные топики и обработку сообщений задаёт вызывающий
* хук через onConnect. * хук через onConnect.
* *
* Соединение пересоздаётся не только при смене enabled, но и при смене
* onConnect: подписка живёт внутри клиента, поэтому новый onConnect (например,
* новый chatId → новый топик) обязан переподписать заново, иначе клиент останется
* подписан на предыдущий топик, а отправлять будет в новый.
*
* @param {boolean} enabled — активировать соединение (например, есть ли userId/chatId) * @param {boolean} enabled — активировать соединение (например, есть ли userId/chatId)
* @param {(client: Client) => void} onConnect — вызывается при успешном коннекте, * @param {(client: Client) => void} onConnect — вызывается при успешном коннекте,
* внутри обычно делают client.subscribe(...) * внутри обычно делают client.subscribe(...)
@@ -28,7 +33,10 @@ export function useStompClient(enabled, onConnect) {
}, [onConnect]); }, [onConnect]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) {
setConnected(false);
return;
}
const client = new Client({ const client = new Client({
webSocketFactory: () => new SockJS(`${API_URL}/ws`), webSocketFactory: () => new SockJS(`${API_URL}/ws`),
@@ -43,7 +51,11 @@ export function useStompClient(enabled, onConnect) {
onConnectRef.current?.(client); onConnectRef.current?.(client);
}, },
onDisconnect: () => setConnected(false), onDisconnect: () => setConnected(false),
// stompjs не вызывает onDisconnect при обрыве сокета — без этого флага
// UI продолжал бы считать нас "онлайн", пока идёт реконнект.
onWebSocketClose: () => setConnected(false),
onStompError: async (frame) => { onStompError: async (frame) => {
setConnected(false);
if (frame.headers?.message?.includes("Unauthorized")) { if (frame.headers?.message?.includes("Unauthorized")) {
try { try {
await refreshAccessToken(); await refreshAccessToken();
@@ -54,11 +66,18 @@ export function useStompClient(enabled, onConnect) {
}, },
}); });
client.activate();
clientRef.current = client; clientRef.current = client;
// activate() сам не awaited — без catch любое исключение из beforeConnect
// всплывало бы как необработанный rejected promise.
Promise.resolve(client.activate()).catch((e) => {
console.warn("[stomp] activate failed", e);
});
return () => client.deactivate(); return () => {
}, [enabled]); setConnected(false);
client.deactivate();
};
}, [enabled, onConnect]);
return { clientRef, connected }; return { clientRef, connected };
} }

View File

@@ -18,7 +18,12 @@ export function useTranscodingStatusPolling(advertisingId, enabled, path = "ads"
const attemptsRef = useRef(0); const attemptsRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!enabled || !advertisingId) return; if (!enabled || !advertisingId) {
// Статус предыдущей сущности нельзя переносить на следующую: иначе
// новый элемент сразу получит терминальный статус и polling сразу выключится.
setStatus('PROCESSING');
return;
}
attemptsRef.current = 0; attemptsRef.current = 0;
let cancelled = false; let cancelled = false;
@@ -37,7 +42,7 @@ export function useTranscodingStatusPolling(advertisingId, enabled, path = "ads"
const data = await res.json(); const data = await res.json();
if (cancelled) return; if (cancelled) return;
setStatus(data.transcodingStatus); setStatus(data.transcodingStatus ?? 'PROCESSING');
attemptsRef.current += 1; attemptsRef.current += 1;
const isTerminal = TERMINAL_STATUSES.includes(data.transcodingStatus); const isTerminal = TERMINAL_STATUSES.includes(data.transcodingStatus);
@@ -49,7 +54,15 @@ export function useTranscodingStatusPolling(advertisingId, enabled, path = "ads"
setStatus('TIMEOUT'); setStatus('TIMEOUT');
} }
} catch (err) { } catch (err) {
if (cancelled) return;
console.warn('Status poll failed, retrying', err); console.warn('Status poll failed, retrying', err);
// Ошибки тоже тратят попытки, иначе MAX_POLL_ATTEMPTS недостижим
// и запрос к несуществующему эндпоинту будет повторяться вечно.
attemptsRef.current += 1;
if (attemptsRef.current >= MAX_POLL_ATTEMPTS) {
setStatus('TIMEOUT');
return;
}
timeoutId = setTimeout(poll, POLL_INTERVAL_MS); timeoutId = setTimeout(poll, POLL_INTERVAL_MS);
} }
}; };

View File

@@ -1,30 +0,0 @@
import { useEffect, useState } from 'react';
import { useStompClient } from './useStompClient'; // ваш существующий хук из чата
/**
* STOMP/WebSocket-подписка на статус транскодинга медиа.
* @param {number|string} advertisingId — id сущности
* @param {boolean} enabled — включать подписку
* @param {string} [path="ads"] — сегмент пути (например, "ads" или "fullscreen-ads")
* @returns {string} транскодинг-статус
*/
export function useTranscodingStatusSocket(advertisingId, enabled, path = "ads") {
const [status, setStatus] = useState('PROCESSING');
const stompClient = useStompClient();
useEffect(() => {
if (!enabled || !advertisingId || !stompClient?.connected) return;
const subscription = stompClient.subscribe(
`/topic/${path}/${advertisingId}/status`,
(message) => {
const payload = JSON.parse(message.body);
setStatus(payload.transcodingStatus);
}
);
return () => subscription.unsubscribe();
}, [advertisingId, enabled, path, stompClient]);
return status;
}

View File

@@ -1,10 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { whenYmapsReady } from "../utils/ymapsApi";
const YANDEX_GEOCODER_API_KEY = process.env.REACT_APP_YANDEX_GEOCODER_KEY; const YANDEX_GEOCODER_API_KEY = process.env.REACT_APP_YANDEX_GEOCODER_KEY;
const DEFAULT_CENTER = [55.751574, 37.573856]; const DEFAULT_CENTER = [55.751574, 37.573856];
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") { export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
const { t } = useTranslation();
// tRef читается из эффекта инициализации карты. Добавлять t в его deps нельзя:
// смена языка тогда пересоздавала бы карту и теряла бы выбранную метку.
const tRef = useRef(t);
tRef.current = t;
const mapRef = useRef(null); const mapRef = useRef(null);
const ymapRef = useRef(null); const ymapRef = useRef(null);
const placemarkRef = useRef(null); const placemarkRef = useRef(null);
@@ -16,6 +23,8 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
const [address, setAddress] = useState(initialAddress ?? ""); const [address, setAddress] = useState(initialAddress ?? "");
const addressRef = useRef(initialAddress ?? ""); const addressRef = useRef(initialAddress ?? "");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Ошибка загрузки API карт: без неё пользователь смотрел бы в вечный спиннер.
const [loadError, setLoadError] = useState("");
// Всегда хранит последние координаты, чтобы при повторной инициализации // Всегда хранит последние координаты, чтобы при повторной инициализации
// карты (например, при повторном открытии модалки) маркер появлялся там, // карты (например, при повторном открытии модалки) маркер появлялся там,
// где пользователь его оставил, а не сбрасывался на initialCoords. // где пользователь его оставил, а не сбрасывался на initialCoords.
@@ -127,13 +136,30 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
useEffect(() => { useEffect(() => {
if (!enabled || !mapRef.current) return; if (!enabled || !mapRef.current) return;
if (initializedRef.current) return; // уже инициализировали if (initializedRef.current) return; // уже инициализировали
initializedRef.current = true;
if (window.ymaps?.ready) { let cancelled = false;
window.ymaps.ready(init);
} // Сбрасываем прошлую ошибку на новой попытке, иначе после успешной
// загрузки карты старая плашка об ошибке осталась бы на экране.
setLoadError("");
whenYmapsReady()
.then((ymaps) => {
if (cancelled) return;
initializedRef.current = true;
ymaps.ready(init);
})
.catch((e) => {
if (cancelled) return;
// API карт так и не подгрузился: сбрасываем флаг, чтобы следующая
// попытка открытия карты её повторила, и гасим бесконечный спиннер.
initializedRef.current = false;
setLoadError(tRef.current("post.mapLoadFailed"));
console.warn("Yandex Maps API failed to load:", e);
});
return () => { return () => {
cancelled = true;
ymapRef.current?.destroy(); ymapRef.current?.destroy();
ymapRef.current = null; ymapRef.current = null;
placemarkRef.current = null; placemarkRef.current = null;
@@ -146,5 +172,5 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, init]); }, [enabled, init]);
return { mapRef, coords, address, addressRef, loading, moveTo }; return { mapRef, coords, address, addressRef, loading, loadError, moveTo };
} }

34
src/i18n.js Normal file
View File

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

14
src/legacy.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/**
* Глобальные (asset) типы для проекта.
*
* Проект — гибрид JSX + TS: основная кодовая база пока на .js/.jsx, и TS
* инферирует их типы «best-effort». Новые файлы пишутся на TypeScript,
* а legacy-импорты в них «закорачиваются» кастами прямо в месте использования
* (см. MainFeed.tsx), поэтому для .js/.jsx здесь не нужно объявлений.
*/
declare module "*.css";
declare module "*.scss";
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.svg";

View File

@@ -80,7 +80,8 @@
"registrationError": "Registration error", "registrationError": "Registration error",
"nameLabel": "Name", "nameLabel": "Name",
"loginMinChars": "Minimum 3 characters", "loginMinChars": "Minimum 3 characters",
"accountCreatedLoginManually": "Account created. Log in manually." "accountCreatedLoginManually": "Account created. Log in manually.",
"autoLoginFailed": "Account created, but automatic sign-in failed — please log in manually."
}, },
"post": { "post": {
"editTitle": "Edit ad", "editTitle": "Edit ad",
@@ -110,6 +111,7 @@
"geoDenied": "Location access denied", "geoDenied": "Location access denied",
"geoUnsupported": "Geolocation is not available in this browser", "geoUnsupported": "Geolocation is not available in this browser",
"geoTimeout": "Unable to determine your location", "geoTimeout": "Unable to determine your location",
"mapLoadFailed": "Could not load the map. Check your connection or ad blocker and try again.",
"addressLabel": "Address", "addressLabel": "Address",
"addressPlaceholder": "E.g. 123 Main St, New York", "addressPlaceholder": "E.g. 123 Main St, New York",
"phoneLabel": "Phone number", "phoneLabel": "Phone number",
@@ -130,8 +132,11 @@
"deleteError": "Failed to delete the ad", "deleteError": "Failed to delete the ad",
"deleteErrorWithStatus": "Failed to delete ad (error {{status}})", "deleteErrorWithStatus": "Failed to delete ad (error {{status}})",
"statusUpdateError": "Failed to change the ad status.", "statusUpdateError": "Failed to change the ad status.",
"serverError": "The server returned an error ({{status}})",
"unknownError": "Unknown error",
"nextStep": "Next", "nextStep": "Next",
"backStep": "Back" "backStep": "Back",
"mapNoCoords": "This listing has no coordinates, so there is no location to show on the map."
}, },
"feed": { "feed": {
"loading": "Loading ads...", "loading": "Loading ads...",
@@ -148,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…",
@@ -165,9 +172,13 @@
"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",
"showOnMap": "Show on map — {{address}}", "showOnMap": "Show on map — {{address}}",
"contactError": "Unable to open the chat. Please try again", "contactError": "Unable to open the chat. Please try again",
"reward": "Reward" "reward": "Reward"
@@ -204,7 +215,9 @@
"loading": "Loading reviews...", "loading": "Loading reviews...",
"emptyTitle": "No reviews yet", "emptyTitle": "No reviews yet",
"emptySub": "Be the first — leave a review about the platform", "emptySub": "Be the first — leave a review about the platform",
"you": "You" "you": "You",
"notSaved": "not saved",
"submitError": "Failed to submit your review"
}, },
"rightSidebar": { "rightSidebar": {
"advertisement": "Advertisement", "advertisement": "Advertisement",
@@ -238,7 +251,40 @@
}, },
"adminPanel": { "adminPanel": {
"campaignsNav": "Ads (campaigns)", "campaignsNav": "Ads (campaigns)",
"postsNav": "All listings",
"postsRejectedNav": "Rejected",
"tabsLabel": "Admin panel tabs",
"tabCampaigns": "Ad campaigns",
"tabPosts": "All listings",
"tabPostsRejected": "Rejected",
"pageTitle": "Advertising campaigns", "pageTitle": "Advertising campaigns",
"postsPageTitle": "All listings",
"postsSearchPlaceholder": "Search by title, description, author",
"postsTypeFilter": "Listing type",
"postsStatusFilter": "Listing status",
"filterTypeAll": "All",
"filterStatusAll": "Any",
"statusActive": "Active",
"statusClosed": "Closed",
"postsLoading": "Loading listings...",
"postsHint": "Loaded listings: {{count}}",
"postsLoadError": "Failed to load listings",
"postsEmpty": "No listings",
"postsEmptySub": "Nothing found for the current filter.",
"postsUpdated": "Listing updated",
"postsDeleted": "Listing deleted",
"postsDeleteFailed": "Failed to delete the listing",
"postsClosed": "Listing closed",
"postsReopened": "Listing returned to the feed",
"postsStatusFailed": "Failed to change the listing status",
"postsConfirmDelete": "Delete this listing? This cannot be undone.",
"postsConfirmClose": "Close the listing (mark as found)?",
"postsConfirmReopen": "Return this listing to the feed?",
"postEditTitle": "Edit listing (admin)",
"postUpdatedModal": "Listing saved",
"postUpdatedModalSub": "The changes are already visible in the feed.",
"loadMore": "Load more",
"refresh": "Refresh",
"persistHint": "Campaigns and creatives are stored on the server", "persistHint": "Campaigns and creatives are stored on the server",
"campaignsLoading": "Loading campaigns...", "campaignsLoading": "Loading campaigns...",
"campaignsLoadError": "Failed to load campaigns", "campaignsLoadError": "Failed to load campaigns",
@@ -397,8 +443,29 @@
"rulesRequired": "You must agree to the platform rules" "rulesRequired": "You must agree to the platform rules"
}, },
"calendar": { "calendar": {
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "months": [
"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] "January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
],
"days": [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
]
}, },
"support": { "support": {
"title": "Support", "title": "Support",

View File

@@ -80,7 +80,8 @@
"registrationError": "Ошибка регистрации", "registrationError": "Ошибка регистрации",
"nameLabel": "Имя", "nameLabel": "Имя",
"loginMinChars": "Минимум 3 символа", "loginMinChars": "Минимум 3 символа",
"accountCreatedLoginManually": "Аккаунт создан. Войдите вручную" "accountCreatedLoginManually": "Аккаунт создан. Войдите вручную",
"autoLoginFailed": "Аккаунт создан, но войти автоматически не удалось — войдите вручную"
}, },
"post": { "post": {
"editTitle": "Редактировать объявление", "editTitle": "Редактировать объявление",
@@ -110,6 +111,7 @@
"geoDenied": "Доступ к геолокации запрещён", "geoDenied": "Доступ к геолокации запрещён",
"geoUnsupported": "Геолокация недоступна в этом браузере", "geoUnsupported": "Геолокация недоступна в этом браузере",
"geoTimeout": "Не удалось определить местоположение", "geoTimeout": "Не удалось определить местоположение",
"mapLoadFailed": "Не удалось загрузить карту. Проверьте интернет или блокировщик рекламы и попробуйте снова.",
"addressLabel": "Адрес", "addressLabel": "Адрес",
"addressPlaceholder": "Например: Москва, ул. Ленина, 5", "addressPlaceholder": "Например: Москва, ул. Ленина, 5",
"phoneLabel": "Номер телефона", "phoneLabel": "Номер телефона",
@@ -130,9 +132,11 @@
"deleteError": "Не удалось удалить объявление", "deleteError": "Не удалось удалить объявление",
"deleteErrorWithStatus": "Не удалось удалить объявление (ошибка {{status}})", "deleteErrorWithStatus": "Не удалось удалить объявление (ошибка {{status}})",
"statusUpdateError": "Не удалось изменить статус объявления", "statusUpdateError": "Не удалось изменить статус объявления",
"serverError": "Сервер вернул ошибку {{status}}",
"unknownError": "Неизвестная ошибка",
"nextStep": "Далее", "nextStep": "Далее",
"backStep": "Назад" "backStep": "Назад",
"mapNoCoords": "У объявления не указаны координаты — показывать место на карте нечего."
}, },
"feed": { "feed": {
"loading": "Загружаем объявления...", "loading": "Загружаем объявления...",
@@ -149,6 +153,8 @@
"filterLoss": "Потери", "filterLoss": "Потери",
"filterAll": "Все объявления", "filterAll": "Все объявления",
"filterFound": "Находки", "filterFound": "Находки",
"filterActive": "Активные",
"filterClosed": "Найденные",
"openFilter": "Фильтр по типу объявления", "openFilter": "Фильтр по типу объявления",
"openMenu": "Открыть меню", "openMenu": "Открыть меню",
"filterCitiesPlaceholder": "Выберите город…", "filterCitiesPlaceholder": "Выберите город…",
@@ -166,9 +172,13 @@
"metro": "Метрополитен", "metro": "Метрополитен",
"loss": "Потеря", "loss": "Потеря",
"found": "Находка", "found": "Находка",
"foundStatus": "✓ Найдено", "foundStatus": "Найдено",
"petFoundStatus": "Питомец нашёлся! 🎉",
"foundStatusWithDate": "{{status}} — {{date}}",
"undoFound": "Вернуть в поиск",
"write": "Написать", "write": "Написать",
"itemFound": "✓ Вернулось к владельцу", "itemFound": "✓ Вернулось к владельцу",
"reopen": "↩ Вернуть в ленту",
"showOnMap": "Показать на карте — {{address}}", "showOnMap": "Показать на карте — {{address}}",
"contactError": "Не удалось открыть чат. Попробуйте ещё раз", "contactError": "Не удалось открыть чат. Попробуйте ещё раз",
"reward": "Вознаграждение" "reward": "Вознаграждение"
@@ -205,7 +215,9 @@
"loading": "Загружаем отзывы...", "loading": "Загружаем отзывы...",
"emptyTitle": "Отзывов пока нет", "emptyTitle": "Отзывов пока нет",
"emptySub": "Станьте первым — оставьте отзыв о площадке", "emptySub": "Станьте первым — оставьте отзыв о площадке",
"you": "Вы" "you": "Вы",
"notSaved": "не сохранён",
"submitError": "Не удалось отправить отзыв"
}, },
"rightSidebar": { "rightSidebar": {
"advertisement": "Реклама", "advertisement": "Реклама",
@@ -239,7 +251,40 @@
}, },
"adminPanel": { "adminPanel": {
"campaignsNav": "Реклама (кампании)", "campaignsNav": "Реклама (кампании)",
"postsNav": "Все объявления",
"postsRejectedNav": "Отклонённые",
"tabsLabel": "Вкладки админ-панели",
"tabCampaigns": "Рекламные кампании",
"tabPosts": "Все объявления",
"tabPostsRejected": "Отклонённые",
"pageTitle": "Рекламные кампании", "pageTitle": "Рекламные кампании",
"postsPageTitle": "Все объявления",
"postsSearchPlaceholder": "Поиск по названию, описанию, автору",
"postsTypeFilter": "Тип объявления",
"postsStatusFilter": "Статус объявления",
"filterTypeAll": "Все",
"filterStatusAll": "Любой",
"statusActive": "Активные",
"statusClosed": "Закрытые",
"postsLoading": "Загружаем объявления…",
"postsHint": "Загружено объявлений: {{count}}",
"postsLoadError": "Не удалось загрузить объявления",
"postsEmpty": "Объявлений нет",
"postsEmptySub": "Ничего не найдено по текущему фильтру.",
"postsUpdated": "Объявление обновлено",
"postsDeleted": "Объявление удалено",
"postsDeleteFailed": "Не удалось удалить объявление",
"postsClosed": "Объявление закрыто",
"postsReopened": "Объявление возвращено в ленту",
"postsStatusFailed": "Не удалось изменить статус объявления",
"postsConfirmDelete": "Удалить объявление? Это действие необратимо.",
"postsConfirmClose": "Закрыть объявление («найдено»)?",
"postsConfirmReopen": "Вернуть объявление в ленту?",
"postEditTitle": "Редактирование объявления (админ)",
"postUpdatedModal": "Объявление сохранено",
"postUpdatedModalSub": "Изменения уже видны в ленте.",
"loadMore": "Показать ещё",
"refresh": "Обновить",
"persistHint": "Кампании и объявления хранятся на сервере", "persistHint": "Кампании и объявления хранятся на сервере",
"campaignsLoading": "Загружаем кампании…", "campaignsLoading": "Загружаем кампании…",
"campaignsLoadError": "Не удалось загрузить кампании", "campaignsLoadError": "Не удалось загрузить кампании",
@@ -398,8 +443,29 @@
"rulesRequired": "Необходимо согласиться с правилами размещения площадки" "rulesRequired": "Необходимо согласиться с правилами размещения площадки"
}, },
"calendar": { "calendar": {
"months": ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"], "months": [
"days": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] "Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь"
],
"days": [
"Пн",
"Вт",
"Ср",
"Чт",
"Пт",
"Сб",
"Вс"
]
}, },
"support": { "support": {
"title": "Служба поддержки", "title": "Служба поддержки",

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, Navigate } from "react-router-dom"; import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuthContext } from "../context/AuthContext"; import { useAuthContext } from "../context/AuthContext";
import { useIsDesktop } from "../hooks/useIsDesktop"; import { useIsDesktop } from "../hooks/useIsDesktop";
@@ -17,6 +17,7 @@ import CampaignEditModal from "../components/admin/CampaignEditModal";
import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal"; import AdvertisingCreateModal from "../components/admin/AdvertisingCreateModal";
import AdvertisingEditModal from "../components/admin/AdvertisingEditModal"; import AdvertisingEditModal from "../components/admin/AdvertisingEditModal";
import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal"; import CampaignStatusUpdateModal from "../components/admin/CampaignStatusUpdateModal";
import PostsTab from "../components/admin/PostsTab";
import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi"; import { getAdvertisingStatus, getCampaigns, getAdvertisingsByCampaign } from "../api/adminAdsApi";
import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus"; import { getNextCampaignStatuses, isProcessingTranscoding } from "../utils/campaignStatus";
import "../styles/admin.css"; import "../styles/admin.css";
@@ -24,6 +25,16 @@ import "../styles/admin.css";
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const MAX_POLL_ATTEMPTS = 100; // ≈ 5 минут const MAX_POLL_ATTEMPTS = 100; // ≈ 5 минут
/** Активная вкладка задаётся query-параметром ?tab=posts или ?tab=rejected, иначе — реклама. */
const TAB_CAMPAIGNS = "campaigns";
const TAB_POSTS = "posts";
const TAB_POSTS_REJECTED = "rejected";
const TABS = [
{ key: TAB_CAMPAIGNS, labelKey: "adminPanel.tabCampaigns" },
{ key: TAB_POSTS, labelKey: "adminPanel.tabPosts" },
{ key: TAB_POSTS_REJECTED, labelKey: "adminPanel.tabPostsRejected" },
];
/** "2026-09-20T14:30:00" → "20.09.2026 14:30" */ /** "2026-09-20T14:30:00" → "20.09.2026 14:30" */
function formatLocalDateTime(value) { function formatLocalDateTime(value) {
if (!value) return "—"; if (!value) return "—";
@@ -33,7 +44,8 @@ function formatLocalDateTime(value) {
} }
/** /**
* Десктоп-only админ-панель управления рекламными кампаниями. * Десктоп-only админ-панель: две вкладки — рекламные кампании
* и все объявления площадки (редактирование, смена статуса, удаление).
* *
* Список кампаний подгружается с бэкенда при монтировании * Список кампаний подгружается с бэкенда при монтировании
* (GET /api/v1/admin/campaigns), а объявления конкретной кампании — * (GET /api/v1/admin/campaigns), а объявления конкретной кампании —
@@ -44,11 +56,26 @@ function formatLocalDateTime(value) {
export default function AdminAdsPage() { export default function AdminAdsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext(); const { user, isAuthenticated, isAdmin, updateUser } = useAuthContext();
const { fetchProfile } = useProfile(); const { fetchProfile } = useProfile();
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const modals = useModals(); const modals = useModals();
const tabParam = searchParams.get("tab");
const tab = tabParam === TAB_POSTS ? TAB_POSTS : tabParam === TAB_POSTS_REJECTED ? TAB_POSTS_REJECTED : TAB_CAMPAIGNS;
const isPostsTab = tab === TAB_POSTS;
const isPostsRejectedTab = tab === TAB_POSTS_REJECTED;
const selectTab = useCallback((key) => {
// Для рекламы параметр не пишем — /admin/ads без query открывает именно её.
if (key === TAB_CAMPAIGNS) {
setSearchParams({}, { replace: true });
} else {
setSearchParams({ tab: key }, { replace: true });
}
}, [setSearchParams]);
const [campaigns, setCampaigns] = useState([]); // { id, name, status, startDate, endDate, dailyImpressionCap, ads: [], adsLoaded, adsLoading, adsLoadError, adsAttempted } const [campaigns, setCampaigns] = useState([]); // { id, name, status, startDate, endDate, dailyImpressionCap, ads: [], adsLoaded, adsLoading, adsLoadError, adsAttempted }
const [campaignsLoading, setCampaignsLoading] = useState(true); const [campaignsLoading, setCampaignsLoading] = useState(true);
const [campaignsLoadError, setCampaignsLoadError] = useState(""); const [campaignsLoadError, setCampaignsLoadError] = useState("");
@@ -83,8 +110,9 @@ export default function AdminAdsPage() {
}, [isAuthenticated, user?.id]); }, [isAuthenticated, user?.id]);
// Подгружаем реальный список кампаний при монтировании // Подгружаем реальный список кампаний при монтировании
// (и при каждом возврате на вкладку рекламы — данные могли устареть).
useEffect(() => { useEffect(() => {
if (!isAdmin) return; if (!isAdmin || isPostsTab) return;
let cancelled = false; let cancelled = false;
setCampaignsLoading(true); setCampaignsLoading(true);
setCampaignsLoadError(""); setCampaignsLoadError("");
@@ -110,7 +138,7 @@ export default function AdminAdsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isAdmin, t]); }, [isAdmin, isPostsTab, t]);
const showToast = useCallback((message, type = "success") => { const showToast = useCallback((message, type = "success") => {
setToast({ message, type }); setToast({ message, type });
@@ -278,7 +306,7 @@ export default function AdminAdsPage() {
<div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}> <div className={`f-app${sidebarOpen ? " f-sb-open" : ""}`}>
<Sidebar <Sidebar
open={sidebarOpen} open={sidebarOpen}
activeNav="home" activeNav={isPostsTab ? "admin-posts" : isPostsRejectedTab ? "admin-posts-rejected" : "admin-campaigns"}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
onNavClick={() => navigate("/feed")} onNavClick={() => navigate("/feed")}
onLogoClick={() => navigate("/feed")} onLogoClick={() => navigate("/feed")}
@@ -298,162 +326,191 @@ export default function AdminAdsPage() {
onProfileClick={() => modals.open("profile")} onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")} onLoginClick={() => modals.open("auth")}
hideWordmark hideWordmark
centerContent={<h1 className="admp-title">{t('adminPanel.pageTitle')}</h1>} centerContent={
<h1 className="admp-title">
{isPostsTab
? t('adminPanel.postsPageTitle')
: isPostsRejectedTab
? t('adminPanel.tabPostsRejected')
: t('adminPanel.pageTitle')}
</h1>
}
/> />
<div className="admp"> <div className="admp">
<div className="admp-inner"> <div className={`admp-inner${isPostsTab ? " admp-inner--posts" : ""}`}>
<div className="admp-toolbar"> <div className="admp-tabs" role="tablist" aria-label={t('adminPanel.tabsLabel')}>
<div> {TABS.map(({ key, labelKey }) => (
<div className="admp-session-hint"> <button
{campaignsLoading key={key}
? t('adminPanel.campaignsLoading') type="button"
: t('adminPanel.persistHint')} role="tab"
</div> aria-selected={tab === key}
{campaignsLoadError && <div className="f-err-msg admp-load-error">{campaignsLoadError}</div>} className={`admp-tab${tab === key ? " active" : ""}`}
</div> onClick={() => selectTab(key)}
<button >
type="button" {t(labelKey)}
className="f-post-btn" </button>
onClick={() => modals.open("campaign-create")} ))}
>
<span>+</span> {t('adminPanel.createCampaign')}
</button>
</div> </div>
{!campaignsLoading && campaigns.length === 0 && ( {isPostsTab || isPostsRejectedTab ? (
<div className="f-center admp-empty"> <PostsTab isAdmin={isAdmin} mode={isPostsRejectedTab ? "rejected" : "all"} />
<div className="f-center-icon">📣</div> ) : (
<div className="f-center-title">{t('adminPanel.noCampaigns')}</div> <>
<div className="f-center-sub">{t('adminPanel.noCampaignsSub')}</div> <div className="admp-toolbar">
<div>
<div className="admp-session-hint">
{campaignsLoading
? t('adminPanel.campaignsLoading')
: t('adminPanel.persistHint')}
</div>
{campaignsLoadError && <div className="f-err-msg admp-load-error">{campaignsLoadError}</div>}
</div>
<button
type="button"
className="f-post-btn"
onClick={() => modals.open("campaign-create")}
>
<span>+</span> {t('adminPanel.createCampaign')}
</button>
</div> </div>
)}
{campaigns.map((campaign) => { {!campaignsLoading && campaigns.length === 0 && (
const allowedNext = getNextCampaignStatuses(campaign.status); <div className="f-center admp-empty">
return ( <div className="f-center-icon">📣</div>
<div className="admp-campaign" key={campaign.id}> <div className="f-center-title">{t('adminPanel.noCampaigns')}</div>
<div className="admp-campaign-head"> <div className="f-center-sub">{t('adminPanel.noCampaignsSub')}</div>
<div className="admp-campaign-name"> </div>
{campaign.name} )}
<span className={`admp-badge admp-badge--${String(campaign.status).toLowerCase()}`}>
{campaign.status} {campaigns.map((campaign) => {
</span> const allowedNext = getNextCampaignStatuses(campaign.status);
</div> return (
<div className="admp-campaign-actions"> <div className="admp-campaign" key={campaign.id}>
<button <div className="admp-campaign-head">
type="button" <div className="admp-campaign-name">
className="admp-btn admp-btn--ghost" {campaign.name}
onClick={() => handleToggleCampaign(campaign.id)} <span className={`admp-badge admp-badge--${String(campaign.status).toLowerCase()}`}>
> {campaign.status}
{expandedCampaignIds.has(campaign.id) </span>
? t('adminPanel.hideAds') </div>
: t('adminPanel.showAds')} <div className="admp-campaign-actions">
</button>
<button
type="button"
className="admp-btn admp-btn--ghost"
onClick={() => modals.open("campaign-edit", { campaign })}
>
{t('adminPanel.editCampaign')}
</button>
{allowedNext.length > 0 && (
<button <button
type="button" type="button"
className="admp-btn admp-btn--ghost" className="admp-btn admp-btn--ghost"
onClick={() => onClick={() => handleToggleCampaign(campaign.id)}
modals.open("campaign-status-update", {
campaignId: campaign.id,
currentStatus: campaign.status,
})
}
> >
{t('adminPanel.changeStatus')} {expandedCampaignIds.has(campaign.id)
? t('adminPanel.hideAds')
: t('adminPanel.showAds')}
</button> </button>
)} <button
<button type="button"
type="button" className="admp-btn admp-btn--ghost"
className="admp-btn" onClick={() => modals.open("campaign-edit", { campaign })}
onClick={() => modals.open("advertising-create", { campaignId: campaign.id })} >
> {t('adminPanel.editCampaign')}
+ {t('adminPanel.addAd')} </button>
</button> {allowedNext.length > 0 && (
</div> <button
</div> type="button"
className="admp-btn admp-btn--ghost"
onClick={() =>
modals.open("campaign-status-update", {
campaignId: campaign.id,
currentStatus: campaign.status,
})
}
>
{t('adminPanel.changeStatus')}
</button>
)}
<button
type="button"
className="admp-btn"
onClick={() => modals.open("advertising-create", { campaignId: campaign.id })}
>
+ {t('adminPanel.addAd')}
</button>
</div>
</div>
<div className="admp-meta"> <div className="admp-meta">
<span>{t('adminPanel.dates')}: <strong>{formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)}</strong></span> <span>{t('adminPanel.dates')}: <strong>{formatLocalDateTime(campaign.startDate)} — {formatLocalDateTime(campaign.endDate)}</strong></span>
<span>{t('adminPanel.dailyCap')}: <strong>{campaign.dailyImpressionCap}</strong></span> <span>{t('adminPanel.dailyCap')}: <strong>{campaign.dailyImpressionCap}</strong></span>
</div> </div>
{expandedCampaignIds.has(campaign.id) && ( {expandedCampaignIds.has(campaign.id) && (
<> <>
<div className="admp-section-title">{t('adminPanel.ads')}</div> <div className="admp-section-title">{t('adminPanel.ads')}</div>
{campaign.adsLoading ? ( {campaign.adsLoading ? (
<div className="admp-no-ads">{t('adminPanel.adsLoading')}</div> <div className="admp-no-ads">{t('adminPanel.adsLoading')}</div>
) : campaign.adsLoadError ? ( ) : campaign.adsLoadError ? (
<div className="f-err-msg admp-load-error">{campaign.adsLoadError}</div> <div className="f-err-msg admp-load-error">{campaign.adsLoadError}</div>
) : campaign.ads.length === 0 ? ( ) : campaign.ads.length === 0 ? (
<div className="admp-no-ads">{t('adminPanel.noAds')}</div> <div className="admp-no-ads">{t('adminPanel.noAds')}</div>
) : ( ) : (
<div className="admp-ads"> <div className="admp-ads">
{campaign.ads.map((ad) => ( {campaign.ads.map((ad) => (
<div className="admp-ad" key={ad.id}> <div className="admp-ad" key={ad.id}>
<span className="admp-ad-type">{ad.type}</span> <span className="admp-ad-type">{ad.type}</span>
<div className="admp-ad-info"> <div className="admp-ad-info">
<div className="admp-ad-url">{ad.targetUrl}</div> <div className="admp-ad-url">{ad.targetUrl}</div>
<div className="admp-ad-sub"> <div className="admp-ad-sub">
{t('adminPanel.weight')}: {ad.weight} {t('adminPanel.weight')}: {ad.weight}
</div>
</div>
<span className={`admp-badge admp-badge--${transcodingBadgeClass(ad)}`}>
{transcodingBadgeText(ad, t)}
</span>
<button
type="button"
className="admp-btn admp-btn--ghost admp-ad-edit"
onClick={() => modals.open("advertising-edit", { ad })}
>
{t('adminPanel.editAdvertising')}
</button>
</div> </div>
</div> ))}
<span className={`admp-badge admp-badge--${transcodingBadgeClass(ad)}`}> </div>
{transcodingBadgeText(ad, t)} )}
</span> </>
<button )}
type="button" </div>
className="admp-btn admp-btn--ghost admp-ad-edit" );
onClick={() => modals.open("advertising-edit", { ad })} })}
> </>
{t('adminPanel.editAdvertising')} )}
</button>
</div>
))}
</div>
)}
</>
)}
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
{modals.isOpen("campaign-create") && isAdmin && ( {modals.isOpen("campaign-create") && isAdmin && !isPostsTab && (
<CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} /> <CampaignCreateModal onClose={modals.close} onCreated={handleCampaignCreated} />
)} )}
{modals.isOpen("campaign-edit") && isAdmin && ( {modals.isOpen("campaign-edit") && isAdmin && !isPostsTab && (
<CampaignEditModal <CampaignEditModal
campaign={modals.modal.payload.campaign} campaign={modals.modal.payload.campaign}
onClose={modals.close} onClose={modals.close}
onUpdated={handleCampaignUpdated} onUpdated={handleCampaignUpdated}
/> />
)} )}
{modals.isOpen("advertising-create") && isAdmin && ( {modals.isOpen("advertising-create") && isAdmin && !isPostsTab && (
<AdvertisingCreateModal <AdvertisingCreateModal
campaignId={modals.modal.payload.campaignId} campaignId={modals.modal.payload.campaignId}
onClose={modals.close} onClose={modals.close}
onCreated={handleAdCreated} onCreated={handleAdCreated}
/> />
)} )}
{modals.isOpen("advertising-edit") && isAdmin && ( {modals.isOpen("advertising-edit") && isAdmin && !isPostsTab && (
<AdvertisingEditModal <AdvertisingEditModal
ad={modals.modal.payload.ad} ad={modals.modal.payload.ad}
onClose={modals.close} onClose={modals.close}
onUpdated={handleAdUpdated} onUpdated={handleAdUpdated}
/> />
)} )}
{modals.isOpen("campaign-status-update") && isAdmin && ( {modals.isOpen("campaign-status-update") && isAdmin && !isPostsTab && (
<CampaignStatusUpdateModal <CampaignStatusUpdateModal
campaignId={modals.modal.payload.campaignId} campaignId={modals.modal.payload.campaignId}
currentStatus={modals.modal.payload.currentStatus} currentStatus={modals.modal.payload.currentStatus}

View File

@@ -64,7 +64,7 @@ export default function ChatPage() {
return sortMessages([...list, ...extra]); return sortMessages([...list, ...extra]);
}); });
}) })
.catch(() => setLoadError(t('chat.loadError'))); .catch(() => setLoadError(t('chats.loadError')));
markAsRead(chatId); markAsRead(chatId);
clearChatUnread(chatId); clearChatUnread(chatId);
@@ -83,7 +83,7 @@ export default function ChatPage() {
const sent = sendMessage(trimmed); const sent = sendMessage(trimmed);
if (!sent) { if (!sent) {
setSendError(t('chat.sendError')); setSendError(t('chats.sendError'));
return; return;
} }
setInput(""); setInput("");

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import CloseIcon from "../components/icons/CloseIcon"; import CloseIcon from "../components/icons/CloseIcon";
import RulesModal from "./RulesModal"; import RulesModal from "./RulesModal";
import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants"; import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePostMutation } from "../hooks/usePostMutation"; import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload"; import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload";
@@ -15,11 +15,13 @@ import ClearableField from "../components/ClearableField";
/** /**
* CreateUpdatePost — модальная форма создания и редактирования объявления. * CreateUpdatePost — модальная форма создания и редактирования объявления.
* *
* @param {function} onClose — закрыть модалку без сохранения * @param {function} onClose — закрыть модалку без сохранения
* @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту) * @param {function} onSuccess — колбэк после успешного сохранения (перезагружает ленту)
* @param {object} [initialData] — если передан, форма работает в режиме редактирования * @param {object} [initialData] — если передан, форма работает в режиме редактирования
* @param {boolean} [adminMode] — правка чужого объявления из админ-панели:
* сохранение уходит в PUT /api/v1/admin/posts/{id}
*/ */
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { export default function CreateUpdatePost({ onClose, onSuccess, initialData, adminMode = false }) {
const isEdit = !!initialData; const isEdit = !!initialData;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -57,7 +59,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
getExistingUrlsToKeep, getExistingUrlsToKeep,
} = useImageUpload(isEdit ? initialData.images : []); } = useImageUpload(isEdit ? initialData.images : []);
const { save, loading: saving, error: apiError } = usePostMutation(); const { save, loading: saving, error: apiError } = usePostMutation({ adminMode });
const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked" const [resultModal, setResultModal] = useState(null); // null | "success" | "blocked"
@@ -65,7 +67,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
? { latitude: initialData.latitude, longitude: initialData.longitude } ? { latitude: initialData.latitude, longitude: initialData.longitude }
: null; : null;
const { mapRef, coords, address, addressRef, loading: mapLoading, moveTo } = useYandexMap( const { mapRef, coords, address, addressRef, loading: mapLoading, loadError: mapLoadError, moveTo } = useYandexMap(
mapModalOpen, mapModalOpen,
initialCoords, initialCoords,
isEdit ? (initialData.location || "") : "" isEdit ? (initialData.location || "") : ""
@@ -157,7 +159,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
setResultModal("blocked"); setResultModal("blocked");
return; return;
} }
if (result.data) { if (result.ok) {
setResultModal("success"); setResultModal("success");
} }
}; };
@@ -172,7 +174,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title"> <span className="f-modal-title">
{isEdit ? t('post.editTitle') : t('post.newTitle')} {adminMode
? t('adminPanel.postEditTitle')
: isEdit ? t('post.editTitle') : t('post.newTitle')}
</span> </span>
<button className="f-close-btn" type="button" onClick={onClose}> <button className="f-close-btn" type="button" onClick={onClose}>
<CloseIcon /> <CloseIcon />
@@ -297,7 +301,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
onChange={e => set("category", e.target.value)} onChange={e => set("category", e.target.value)}
> >
<option value="">{t('categories.select')}</option> <option value="">{t('categories.select')}</option>
{CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)} {CATEGORIES.map(c => (
<option key={c.value} value={c.value}>{t(c.labelKey)}</option>
))}
</select> </select>
{errors.category && <p className="f-err-msg">{errors.category}</p>} {errors.category && <p className="f-err-msg">{errors.category}</p>}
</div> </div>
@@ -313,6 +319,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{form.address ? `📍 ${form.address}` : t('post.mapPickLabel')} {form.address ? `📍 ${form.address}` : t('post.mapPickLabel')}
</button> </button>
{!coords && <p className="f-hint">{t('post.mapHint')}</p>} {!coords && <p className="f-hint">{t('post.mapHint')}</p>}
{mapLoadError && <p className="f-err-msg">{mapLoadError}</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>} {errors.map && <p className="f-err-msg">{errors.map}</p>}
</div> </div>
@@ -457,10 +464,14 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{resultModal === "blocked" ? "✕" : "✓"} {resultModal === "blocked" ? "✕" : "✓"}
</div> </div>
<h3 className="f-result-title"> <h3 className="f-result-title">
{resultModal === "blocked" ? t('post.blockedTitle') : t('post.successTitle')} {resultModal === "blocked"
? t('post.blockedTitle')
: adminMode ? t('adminPanel.postUpdatedModal') : t('post.successTitle')}
</h3> </h3>
<p className="f-result-text"> <p className="f-result-text">
{resultModal === "blocked" ? t('post.blockedText') : t('post.successText')} {resultModal === "blocked"
? t('post.blockedText')
: adminMode ? t('adminPanel.postUpdatedModalSub') : t('post.successText')}
</p> </p>
<button <button
className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`} className={`f-result-btn ${resultModal === "blocked" ? "err" : "ok"}`}

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css"; import "../styles/index.css";
import { getImageUrl } from "../config/authConstants"; import { getImageUrl, API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import BottomNav from "../components/nav/BottomNav"; import BottomNav from "../components/nav/BottomNav";
import RightSidebar from "../components/RightSidebar"; import RightSidebar from "../components/RightSidebar";
@@ -27,9 +28,12 @@ import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
import { useModals } from "../hooks/useModals"; 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 { 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";
import Toast from "../components/Toast";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
// Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные» // Legacy .jsx-модули: TS инферирует их типы из JS неточно (бывают «странные»
@@ -71,13 +75,20 @@ export default function MainFeed() {
}; };
const { fetchProfile } = useProfile(); const { fetchProfile } = useProfile();
const { cities } = useCities() as { cities: string[]; loading: boolean; error: string }; const { cities } = useCities() as { cities: string[]; loading: boolean; error: string };
// t в этом файле отдаёт DefaultTFuncReturn (string | ReactElement), поэтому
// состояние ошибки типизируем как текст и приводим результат через String().
const { t } = useTranslation(); const { t } = useTranslation();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = useModals() as any; const modals = useModals() as any;
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
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);
@@ -300,18 +311,65 @@ export default function MainFeed() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]); }, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]);
// Клик по локации на карточке — карта с фокусом на этом объявлении // Приводит значение к числу координат или null.
const handleLocationClick = useCallback((ad: Record<string, unknown>) => { //
// Number(null) === 0, а не NaN: без явной проверки на null/пустую строку
// объявление без координат уехало бы в точку (0, 0) вместо догрузки из
// /detail/{id}. Ноль (экватор/Гринвич) сам по себе допустим, поэтому
// отбрасываем только отсутствие значения.
const toCoordinate = (value: unknown): number | null => {
if (value === null || value === undefined) return null;
if (typeof value === "string" && value.trim() === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
// Клик по локации на карточке — карта с фокусом на этом объявлении.
//
// Координаты берём из карточки, а если их там нет (объявление создано без
// карты, либо лента отдаёт null) — догружаем их из /api/v1/posts/detail/{id}.
// Без этого карта открывалась на дефолтном центре Москвы: MapView получает
// lat/lng из URL, а без них инициализируется на MAP_CENTER, и маркера
// объявления в загруженных границах нет — фокус не к чему было привязать.
const handleLocationClick = useCallback(async (ad: Record<string, unknown>) => {
saveFeedState(); saveFeedState();
setLocationError("");
let focusLat = toCoordinate(ad.latitude);
let focusLng = toCoordinate(ad.longitude);
if (focusLat === null || focusLng === null) {
try {
// apiFetch, а не fetch: /detail/{id} владельцу отдаёт его собственные
// неопубликованные посты, а анонимному запросу бэкенд ответит 404.
const res = await apiFetch(`${API_URL}/api/v1/posts/detail/${ad.id}`);
if (res.ok) {
const data = await res.json();
const detailLat = toCoordinate(data?.latitude);
const detailLng = toCoordinate(data?.longitude);
if (detailLat !== null && detailLng !== null) {
focusLat = detailLat;
focusLng = detailLng;
}
}
} catch {
focusLat = null;
focusLng = null;
}
}
if (focusLat === null || focusLng === null) {
setLocationError(String(t('post.mapNoCoords')));
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const params = new URLSearchParams({ view: "map", post: ad.id as any }); const params = new URLSearchParams({ view: "map", post: ad.id as any });
if (ad.latitude != null && ad.longitude != null) { params.set("lat", String(focusLat));
params.set("lat", String(ad.latitude)); params.set("lng", String(focusLng));
params.set("lng", String(ad.longitude));
}
setActiveNav("map"); setActiveNav("map");
navigate(`/feed?${params.toString()}`); navigate(`/feed?${params.toString()}`);
}, [navigate, saveFeedState]); }, [navigate, saveFeedState, t]);
const handleProfileClick = useCallback(() => { const handleProfileClick = useCallback(() => {
if (!isAuthenticated) modals.open("auth"); if (!isAuthenticated) modals.open("auth");
@@ -328,17 +386,32 @@ export default function MainFeed() {
result = feed.ads; result = feed.ads;
} }
// Сравниваем с теми же значениями, что уходят на бэкенд (CATEGORIES),
// а не с русскими литералами: интерфейс-то может быть на английском.
if (activeNav === "items") { if (activeNav === "items") {
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category)); result = result.filter((ad: AnyAds[number]) =>
[CATEGORY_ITEM, CATEGORY_OTHER].includes(ad.category));
} else if (activeNav === "pets") { } else if (activeNav === "pets") {
result = result.filter(ad => ad.category === "Питомец"); result = result.filter((ad: AnyAds[number]) => ad.category === CATEGORY_PET);
} }
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
@@ -377,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">
@@ -427,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")}
@@ -446,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}
/> />
)} )}
@@ -506,7 +584,7 @@ export default function MainFeed() {
}} }}
onLocationClick={(ad: Record<string, unknown>) => { onLocationClick={(ad: Record<string, unknown>) => {
modals.close(); modals.close();
handleLocationClick(ad); void handleLocationClick(ad);
}} }}
onAuthRequired={() => modals.open("auth")} onAuthRequired={() => modals.open("auth")}
/> />
@@ -514,6 +592,17 @@ export default function MainFeed() {
{fullscreenAd && <FullscreenAdOverlay ad={fullscreenAd} onClose={closeFullscreenAd} />} {fullscreenAd && <FullscreenAdOverlay ad={fullscreenAd} onClose={closeFullscreenAd} />}
{/* Ошибки удаления/отметки «найдено»: без этого тоста неудачный запрос
выглядел бы как «ничего не произошло». */}
<Toast
message={actions.error || locationError}
type="error"
onDismiss={() => {
actions.clearError();
setLocationError("");
}}
/>
<BottomNav <BottomNav
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"} activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
hasFilters={hasActiveFilters} hasFilters={hasActiveFilters}

View File

@@ -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, isOwner, 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);
@@ -44,12 +47,21 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
}; };
const imageUrl = getImageUrl(ad.images?.[0]); const imageUrl = getImageUrl(ad.images?.[0]);
const address = ad.city; const address = ad.address || ad.city || ad.district || ad.location;
const createdAt = ad.createdAt const createdAt = ad.createdAt
? new Date(ad.createdAt).toLocaleDateString("ru-RU") ? new Date(ad.createdAt).toLocaleDateString("ru-RU")
: 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, isOwner,
); );
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">
@@ -69,12 +83,12 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
> >
{ad.title} {ad.title}
</div> </div>
{authorLabel && (
<div className="f-card-author-sub">{authorLabel}</div>
)}
</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>
@@ -83,6 +97,14 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
? <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 && (
@@ -102,7 +124,7 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
)} )}
</div> </div>
<div className="f-card-actions-right"> <div className="f-card-actions-right">
{onEdit && isOwner && !isClosed && ( {onEdit && isOwner && (!isClosed || allowEditClosed) && (
<button type="button" className="f-card-edit-btn" <button type="button" className="f-card-edit-btn"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -207,6 +229,30 @@ export default function PostOnFeed({ ad, onEdit, onDelete, onMarkFound, isOwner,
</button> </button>
</div> </div>
)} )}
{isClosed && (
<div className="f-card-found-wrap">
{/* Кнопка «найдено» становится неактивной подтверждением: видно,
что действие уже применено, и повторно не нажать. */}
<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>
)}
{contactError && ( {contactError && (
<Toast <Toast

View File

@@ -18,7 +18,7 @@ import { useProfile } from "../hooks/useProfile";
export default function ReviewsPage() { export default function ReviewsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, token, isAuthenticated, updateUser } = useAuthContext(); const { user, isAuthenticated, updateUser } = useAuthContext();
const { fetchProfile } = useProfile(); const { fetchProfile } = useProfile();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -45,7 +45,6 @@ export default function ReviewsPage() {
reviews, loading, error, submitting, submitError, reviews, loading, error, submitting, submitError,
loadReviews, submitReview, avgRating, distribution, loadReviews, submitReview, avgRating, distribution,
} = useReviews({ } = useReviews({
token,
isAuthenticated, isAuthenticated,
onAuthRequired: () => setAuthModalOpen(true), onAuthRequired: () => setAuthModalOpen(true),
authorFallbackName: profileData.firstName || t('reviews.you'), authorFallbackName: profileData.firstName || t('reviews.you'),

View File

@@ -2,6 +2,13 @@ const STORAGE_KEY = "nakhodka:chatPosts";
const postByChatId = new Map(); const postByChatId = new Map();
/**
* Map не приводит ключи к строке, а chat.id из JSON приходит числом, тогда как
* из useParams() — всегда строкой. Без нормализации запись просто не
* находилась (а после перезагрузки страницы в кеше жили оба варианта ключа).
*/
const key = (chatId) => String(chatId);
/** Восстанавливает кеш постов из localStorage (выживает при перезагрузке страницы). */ /** Восстанавливает кеш постов из localStorage (выживает при перезагрузке страницы). */
function loadFromStorage() { function loadFromStorage() {
try { try {
@@ -32,18 +39,18 @@ function persist() {
loadFromStorage(); loadFromStorage();
export function setPostForChat(chatId, post) { export function setPostForChat(chatId, post) {
postByChatId.set(chatId, post); postByChatId.set(key(chatId), post);
persist(); persist();
} }
export function getPostForChat(chatId) { export function getPostForChat(chatId) {
return postByChatId.get(chatId); return postByChatId.get(key(chatId));
} }
export function cachePostsFromChats(chats) { export function cachePostsFromChats(chats) {
for (const chat of chats) { for (const chat of chats) {
if (chat.post) { if (chat.post) {
postByChatId.set(chat.id, chat.post); postByChatId.set(key(chat.id), chat.post);
} }
} }
if (chats.length) persist(); if (chats.length) persist();
@@ -51,16 +58,6 @@ export function cachePostsFromChats(chats) {
/** Удаляет запись при удалении конкретного чата. */ /** Удаляет запись при удалении конкретного чата. */
export function removePostForChat(chatId) { export function removePostForChat(chatId) {
postByChatId.delete(chatId); postByChatId.delete(key(chatId));
persist(); persist();
} }
/** Полностью очищает кеш — вызывать при logout(), чтобы не хранить данные предыдущей сессии. */
export function clearPostCache() {
postByChatId.clear();
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
// ignore
}
}

View File

@@ -11,6 +11,43 @@
max-width: 860px; max-width: 860px;
margin: 0 auto; margin: 0 auto;
} }
/* Вкладки админ-панели: объявления сеткой нуждаются в более широком контейнере. */
.admp-inner--posts {
max-width: 1180px;
}
/* ── Переключатель вкладок ── */
.admp-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 12px;
}
.admp-tab {
flex: 1;
padding: 9px 14px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--muted);
font-family: var(--font);
font-size: .85rem;
font-weight: 800;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.admp-tab:hover {
color: var(--text-primary);
}
.admp-tab.active {
background: var(--bg-card);
color: var(--teal);
box-shadow: 0 1px 4px rgba(0,0,0,.08);
}
.admp-toolbar { .admp-toolbar {
display: flex; display: flex;
@@ -41,6 +78,59 @@
margin-top: 36px; margin-top: 36px;
} }
/* ── Фильтры вкладки «Все объявления» ── */
.admp-toolbar--filters {
justify-content: flex-start;
margin-bottom: 8px;
}
.admp-search {
flex: 1;
min-width: 220px;
max-width: 320px;
}
.admp-filter-group {
display: flex;
gap: 4px;
padding: 3px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
}
.admp-chip {
padding: 5px 12px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: var(--font);
font-size: .75rem;
font-weight: 800;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.admp-chip:hover {
color: var(--text-primary);
}
.admp-chip.active {
background: var(--bg-card);
color: var(--teal);
}
/* ── Сетка объявлений ── */
.admp-posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 12px;
transition: opacity .15s;
}
.admp-more {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* ── Карточка кампании ── */ /* ── Карточка кампании ── */
.admp-campaign { .admp-campaign {
background: var(--bg-card); background: var(--bg-card);

View File

@@ -1,308 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700;800;900&display=swap');
/* ─── Page ──────────────────────────────────────────────────────────────────── */
.auth-page {
font-family: 'Onest', sans-serif;
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at 30% 40%, #1a2340 0%, #0d1018 60%);
overflow: hidden; position: relative;
}
.bg-glow {
position: fixed; top: 40%; right: 0;
width: 500px; height: 400px;
background: radial-gradient(ellipse, #0f2a22 0%, transparent 70%);
pointer-events: none; opacity: 0.7;
}
/* ─── Floating icons ────────────────────────────────────────────────────────── */
.fi {
position: fixed;
opacity: 0.13;
pointer-events: none;
user-select: none;
animation: float 7s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(-6deg); }
50% { transform: translateY(-18px) rotate(6deg); }
}
/* ─── Card ──────────────────────────────────────────────────────────────────── */
.auth-card {
position: relative; z-index: 10; width: 375px;
background: linear-gradient(155deg, #1d2436 0%, #131720 100%);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 30px; padding: 36px 38px 30px;
box-shadow: 0 40px 100px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.03);
/* height transition — карточка плавно меняет размер при смене формы */
transition: height 0.35s ease;
overflow: hidden;
}
/* Появление карточки / контента */
.card-in {
animation: cardIn .45s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes cardIn {
from { opacity: 0; transform: scale(.92) translateY(30px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Уход карточки при переключении */
.card-out {
animation: cardOut .3s cubic-bezier(.4,0,.6,1) both;
}
@keyframes cardOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(.94) translateY(-20px); }
}
/* Контент формы — появляется снизу при смене */
.form-body {
animation: formIn .4s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes formIn {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
}
/* Переход между шагами регистрации */
@keyframes stepIn {
from { opacity: 0; transform: translateX(18px) scale(.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
/* ─── Logo ──────────────────────────────────────────────────────────────────── */
.logo-wrap { text-align: center; margin-bottom: 2px; }
@keyframes logoPulse {
0%, 100% { filter: drop-shadow(0 0 18px rgba(45,224,200,.45)); }
50% { filter: drop-shadow(0 0 38px rgba(45,224,200,.8)); }
}
/* Apply this class to your MagnifierLogo svg if needed */
.logo-pulse { animation: logoPulse 3s ease-in-out infinite; }
/* ─── Typography ────────────────────────────────────────────────────────────── */
.auth-title {
text-align: center; color: #fff;
font-size: 2rem; font-weight: 900;
letter-spacing: -0.5px; margin: 12px 0 0;
}
.form-subtitle {
text-align: center; color: rgba(255,255,255,0.35);
font-size: 0.83rem; margin: 4px 0 20px;
}
/* ─── Step dots ─────────────────────────────────────────────────────────────── */
.step-dots {
display: flex; align-items: center; justify-content: center;
gap: 6px; margin-bottom: 18px;
}
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,0.15);
transition: all 0.35s cubic-bezier(.34,1.56,.64,1);
}
.dot-active {
width: 26px; border-radius: 4px;
background: #2de0c8;
}
.dot-done {
background: rgba(45,224,200,0.4);
}
/* ─── Fields ────────────────────────────────────────────────────────────────── */
.field-wrap { margin-bottom: 12px; }
.field-label {
display: block; color: rgba(255,255,255,0.45);
font-size: 0.73rem; font-weight: 700;
letter-spacing: 0.6px; margin-bottom: 5px;
text-transform: uppercase; font-family: 'Onest', sans-serif;
}
.field {
width: 100%; box-sizing: border-box;
background: rgba(255,255,255,0.055);
border: 1.5px solid rgba(255,255,255,0.09);
border-radius: 14px; padding: 13px 18px;
color: #fff; font-family: 'Onest', sans-serif;
font-size: 0.93rem; outline: none;
transition: border-color .2s, background .2s, box-shadow .2s;
}
.field:focus {
border-color: #2de0c8;
background: rgba(45,224,200,.07);
box-shadow: 0 0 0 3px rgba(45,224,200,.12);
}
.field::placeholder { color: rgba(255,255,255,.32); }
.field-err { border-color: rgba(255,107,107,0.55) !important; box-shadow: 0 0 0 3px rgba(255,107,107,0.09) !important; }
.field-ok { border-color: rgba(45,224,200,0.45) !important; }
.eye-btn {
position: absolute; right: 13px; top: 50%;
transform: translateY(-50%);
background: none; border: none; cursor: pointer;
color: rgba(255,255,255,0.35); font-size: 1.05rem; line-height: 1;
padding: 0;
}
.input-wrap {
position: relative;
}
.input-wrap .eye-btn {
position: absolute;
right: 13px;
top: 50%;
transform: translateY(-50%);
}
.input-wrap .field {
padding-right: 44px;
}
/* ─── Password strength ──────────────────────────────────────────────────────── */
.strength-bar { display: flex; gap: 4px; margin-top: 7px; }
.seg {
flex: 1; height: 3px; border-radius: 2px;
background: rgba(255,255,255,0.1);
transition: background 0.3s;
}
.sw { background: #ff6b6b; }
.sm { background: #ffd166; }
.ss { background: #2de0c8; }
.strength-label { font-size: 0.72rem; text-align: right; margin-top: 3px; }
/* ─── Messages ──────────────────────────────────────────────────────────────── */
.err-msg {
color: #ff6b6b; font-size: 0.8rem;
margin-top: 5px; padding-left: 2px;
font-family: 'Onest', sans-serif;
}
/* ─── Confirm box ───────────────────────────────────────────────────────────── */
.confirm-box {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px; padding: 16px 20px; margin-bottom: 14px;
}
.confirm-row { display: flex; justify-content: space-between; align-items: center; }
.confirm-label { color: rgba(255,255,255,0.4); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.5px; }
.confirm-val { color: #fff; font-weight: 700; }
.confirm-val.accent { color: #2de0c8; }
.confirm-divider { height: 1px; background: rgba(255,255,255,0.06); margin: 10px 0; }
/* ─── Buttons ───────────────────────────────────────────────────────────────── */
.btn-primary {
width: 100%;
background: linear-gradient(90deg, #1fc9b0, #2de0c8, #1fc9b0);
background-size: 200% 100%;
border: none; border-radius: 14px; padding: 15px;
color: #082320; font-family: 'Onest', sans-serif;
font-size: 1.05rem; font-weight: 900; cursor: pointer;
margin-top: 6px;
box-shadow: 0 6px 24px rgba(45,224,200,0.32);
animation: shimmer 3s linear infinite;
transition: opacity .15s, transform .15s;
}
.btn-primary:hover:not(:disabled) { opacity: .88; transform: translateY(-2px); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.ghost-btn {
display: block; width: 100%; text-align: center;
color: #2de0c8; font-size: 0.87rem; margin-top: 13px;
background: none; border: none; cursor: pointer;
font-family: 'Onest', sans-serif;
transition: opacity .15s;
}
.ghost-btn:hover { opacity: 0.75; }
.back-btn {
display: block; width: 100%; text-align: center;
color: rgba(255,255,255,0.35); font-size: 0.85rem; margin-top: 11px;
background: none; border: none; cursor: pointer;
font-family: 'Onest', sans-serif;
transition: color .15s;
}
.back-btn:hover { color: rgba(255,255,255,0.6); }
/* ─── Switch row ────────────────────────────────────────────────────────────── */
.switch-row {
text-align: center; color: rgba(255,255,255,0.4);
font-size: 0.87rem; margin-top: 13px;
}
.link-btn {
color: #2de0c8; background: none; border: none;
cursor: pointer; font-family: 'Onest', sans-serif;
font-size: inherit; font-weight: 700; padding: 0;
transition: opacity .15s;
}
.link-btn:hover { opacity: 0.75; text-decoration: underline; }
/* ─── Dots btn ──────────────────────────────────────────────────────────────── */
.dots-btn {
position: absolute; top: 18px; right: 20px;
background: none; border: none;
color: rgba(255,255,255,0.25); font-size: 1.3rem;
cursor: pointer; letter-spacing: 3px;
}
/* ─── Success ───────────────────────────────────────────────────────────────── */
.success-wrap { text-align: center; padding: 16px 0 8px; }
.success-icon {
font-size: 3.8rem; display: block; margin-bottom: 14px;
animation: pop .55s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes pop {
from { transform: scale(0) rotate(-20deg); opacity: 0; }
to { transform: scale(1) rotate(0deg); opacity: 1; }
}
.success-title { color: #2de0c8; font-size: 1.45rem; font-weight: 900; margin-bottom: 6px; }
.success-sub { color: rgba(255,255,255,0.4); font-size: 0.88rem; line-height: 1.5; }
.success-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.success-icon {
background-color: #2de0c8;
-webkit-mask: url("../assets/success.png") no-repeat center;
mask: url("../assets/success.png") no-repeat center;
}
.logoSvg {
width: 130px;
height: 130px;
animation: logoPulse 3s ease-in-out infinite;
}
.auth-modal {
width: 420px;
}
.auth-form-error {
margin-bottom: 10px;
}
.auth-submit-btn {
margin-top: 6px;
}
.auth-row {
text-align: center;
margin-top: 14px;
color: rgba(255,255,255,.45);
font-size: .85rem
}

View File

@@ -53,6 +53,11 @@
color: var(--muted); font-size: .78rem; color: var(--muted); font-size: .78rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
/* Подпись автора — только в админ-панели, где видно, чьё это объявление. */
.f-card-author-sub {
color: var(--muted); font-size: .75rem; font-weight: 600;
max-width: 100%; word-break: break-word;
}
.f-card-head-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .f-card-head-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
/* ── Image panel ── */ /* ── Image panel ── */
@@ -227,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;
@@ -259,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;
@@ -286,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,
@@ -327,6 +357,29 @@ 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-found-btn-done {
background: rgba(48,168,94,.14); border-color: rgba(48,168,94,.35);
color: #30a85e; opacity: 1; cursor: default;
}
.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 ── */ /* ── Calendar Popup ── */
.f-date-calendar { .f-date-calendar {

View File

@@ -1,54 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700;800;900&display=swap');
.fi {
position: fixed;
opacity: 0.15;
filter: grayscale(1) brightness(2);
pointer-events: none;
user-select: none;
animation: float 7s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(-6deg); }
50% { transform: translateY(-18px) rotate(6deg); }
}
.card-in {
animation: cardIn .7s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes cardIn {
from { opacity: 0; transform: scale(.86) translateY(40px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes logoPulse {
0%, 100% { filter: drop-shadow(0 0 18px rgba(var(--teal-rgb), .45)); }
50% { filter: drop-shadow(0 0 38px rgba(var(--teal-rgb), .8)); }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.li:focus {
border-color: var(--teal) !important;
background: rgba(var(--teal-rgb), .07) !important;
box-shadow: 0 0 0 3px rgba(var(--teal-rgb), .12) !important;
}
.li::placeholder { color: rgba(255,255,255,.32); }
.btn-l:hover { opacity: .88; transform: translateY(-2px); transition: all .15s; }
.btn-l:active { transform: translateY(0); }
.soc:hover {
background: rgba(255,255,255,.1) !important;
border-color: rgba(255,255,255,.22) !important;
transform: translateY(-1px);
transition: all .2s;
}
.div-line::before, .div-line::after {
content: ''; flex: 1;
height: 1px; background: rgba(255,255,255,.08);
}

View File

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

View File

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

View File

@@ -12,10 +12,23 @@ function generateAdSessionId() {
} }
export function getAdSessionId() { export function getAdSessionId() {
let sessionId = localStorage.getItem(AD_SESSION_KEY); // localStorage бросает в приватном режиме Safari и при отключённом
if (!sessionId) { // хранилище. Здесь это ломало не только трекинг, но и сам клик по рекламе
sessionId = generateAdSessionId(); // (id читается до window.open), поэтому падение деградируем до временного id.
let persisted = null;
try {
persisted = localStorage.getItem(AD_SESSION_KEY);
} catch {
persisted = null;
}
if (persisted) return persisted;
const sessionId = generateAdSessionId();
try {
localStorage.setItem(AD_SESSION_KEY, sessionId); localStorage.setItem(AD_SESSION_KEY, sessionId);
} catch {
// хранилище недоступно — отдаём id без persist
} }
return sessionId; return sessionId;
} }

View File

@@ -14,7 +14,10 @@ export function formatPhone(val) {
: digits; : digits;
let result = "+7"; let result = "+7";
if (local.length > 0) result += " (" + local.slice(0, 3); if (local.length > 0) result += " (" + local.slice(0, 3);
// Скобку закрываем сразу же: иначе при 1–2 цифрах поле показывало
// «+7 (9» / «+7 (91» и каретка прыгала при каждом нажатии.
if (local.length >= 3) result += ") " + local.slice(3, 6); if (local.length >= 3) result += ") " + local.slice(3, 6);
else if (local.length > 0) result += ")";
if (local.length >= 6) result += "-" + local.slice(6, 8); if (local.length >= 6) result += "-" + local.slice(6, 8);
if (local.length >= 8) result += "-" + local.slice(8, 10); if (local.length >= 8) result += "-" + local.slice(8, 10);
return result; return result;

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

@@ -0,0 +1,81 @@
// Дата, когда объявление было отмечено как «найдено».
//
// Бэкенд статус отдаёт, но момент перехода в CLOSED в ответе /api/v1/posts
// сейчас не приходит, а в карточке он нужен («Найдено 3 октября»). Поэтому:
// 1) если поле closedAt/closedDate появится в DTO — берём его (приоритет);
// 2) иначе — дата, которую клиент запомнил в момент нажатия «Вернулось к
// владельцу» (rememberFoundAt), т.е. точная для текущего устройства;
// 3) иначе — null: показываем просто «Найдено», без выдуманной даты.
const STORAGE_KEY = "postFoundAt";
// Хранилище не должно расти бесконечно: держим последние отметки.
const MAX_ENTRIES = 100;
function readStore() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
// Приватный режим / отключённое хранилище — работаем без даты.
return {};
}
}
function writeStore(store) {
try {
const entries = Object.entries(store).slice(-MAX_ENTRIES);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
} catch {
// Не смогли записать — дата просто не запомнится, это не критично.
}
}
export function rememberFoundAt(postId, iso = new Date().toISOString()) {
if (postId === undefined || postId === null) return;
const store = readStore();
store[postId] = iso;
writeStore(store);
}
export function forgetFoundAt(postId) {
const store = readStore();
if (!(postId in store)) return;
delete store[postId];
writeStore(store);
}
export function getFoundAt(ad) {
if (!ad) return null;
return ad.closedAt || ad.closedDate || readStore()[ad.id] || null;
}
/**
* Intl.DateTimeFormat дорого создавать, а дата рендерится в каждой закрытой
* карточке ленты — держим по одному форматтеру на локаль.
*/
const formatterCache = new Map();
function getFormatter(locale) {
const cached = formatterCache.get(locale);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long" });
formatterCache.set(locale, formatter);
return formatter;
}
/**
* «3 октября» / «October 3» — коротко и по-человечески, без года.
* Возвращает null на невалидную дату, чтобы не показывать «Invalid Date».
*/
export function formatFoundDate(value, language) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const locale = language === "en" ? "en-US" : "ru-RU";
try {
return getFormatter(locale).format(date);
} catch {
return date.toLocaleDateString(locale);
}
}

View File

@@ -1,3 +0,0 @@
export function isMobileDevice() {
return /Mobi|Android|iPhone/i.test(navigator.userAgent);
}

View File

@@ -2,14 +2,20 @@ import i18n from '../i18n';
export function validatePostForm(form) { export function validatePostForm(form) {
const errors = {}; const errors = {};
if (!form.title.trim()) errors.title = i18n.t('validation.titleRequired'); // Поля нормализуем через ??: форма могла прийти без ключа (например, из
if (!form.description.trim()) errors.description = i18n.t('validation.descriptionRequired'); // нового вызывающего кода), и .trim() на undefined уронил бы форму
if (!form.category) errors.category = i18n.t('validation.categoryRequired'); // с TypeError вместо нормального сообщения валидации.
const title = (form.title ?? "").trim();
const description = (form.description ?? "").trim();
if (!title) errors.title = i18n.t('validation.titleRequired');
if (!description) errors.description = i18n.t('validation.descriptionRequired');
if (!form.category) errors.category = i18n.t('validation.categoryRequired');
// Телефон обязателен для объявления (в отличие от профиля) — проверяем // Телефон обязателен для объявления (в отличие от профиля) — проверяем
// количество цифр, а не просто пустоту, т.к. form.phone форматируется // количество цифр, а не просто пустоту, т.к. form.phone форматируется
// через formatPhone и всегда содержит минимум "+7". // через formatPhone и всегда содержит минимум "+7".
if (form.phone.replace(/\D/g, "").length < 11) { if ((form.phone ?? "").replace(/\D/g, "").length < 11) {
errors.phone = i18n.t('validation.phoneRequired'); errors.phone = i18n.t('validation.phoneRequired');
} }

32
src/utils/ymapsApi.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* public/index.html подключает API Яндекс.Карт через <script defer>,
* поэтому к моменту первого рендера window.ymaps может быть ещё undefined:
* скрипт не скачался, медленный CDN, блокировщик рекламы, офлайн.
*
* Без ожидания карта просто не создаётся — пользователь видит вечный спиннер
* без единой ошибки. Поэтому ждём появления API, но с таймаутом, и отдаём
* вызывающему явную ошибку вместо бесконечной загрузки.
*/
const DEFAULT_TIMEOUT_MS = 10000;
const POLL_INTERVAL_MS = 100;
export function whenYmapsReady(timeoutMs = DEFAULT_TIMEOUT_MS) {
if (window.ymaps?.ready) return Promise.resolve(window.ymaps);
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (window.ymaps?.ready) {
clearInterval(timer);
resolve(window.ymaps);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
clearInterval(timer);
reject(new Error("YMAPS_LOAD_FAILED"));
}
}, POLL_INTERVAL_MS);
});
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}