diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d63b078 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +node_modules +build +.idea \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e28e815 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..93f5393 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5059f62 --- /dev/null +++ b/.npmrc @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68d4bbf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +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 \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..a3394ea --- /dev/null +++ b/nginx.conf @@ -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; + } +} \ No newline at end of file diff --git a/src/components/ImageViewer.jsx b/src/components/ImageViewer.jsx index 863c711..20c5b41 100644 --- a/src/components/ImageViewer.jsx +++ b/src/components/ImageViewer.jsx @@ -1,13 +1,16 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useTranslation } from 'react-i18next'; import CloseIcon from "./icons/CloseIcon"; +import CheckIcon from "./icons/CheckIcon"; import Toast from "./Toast"; import { getImageUrl } from "../config/authConstants"; +import { CATEGORY_PET } from "../config/feedConstants"; +import { getFoundAt, formatFoundDate } from "../utils/foundAt"; import { useContactSeller } from "../hooks/useContactSeller"; import { useAuthContext } from "../context/AuthContext"; export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [index, setIndex] = useState(initialIndex); const touchStart = useRef(null); const swiped = useRef(false); @@ -65,7 +68,17 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat contactSeller(onClose); }, [contactSeller, onClose]); - const showContact = isAuthenticated && postData && !postData.isOwner && postData.status !== 'CLOSED'; + const isClosed = postData?.status === 'CLOSED'; + const showContact = isAuthenticated && postData && !postData.isOwner && !isClosed; + // Статус «найдено» держим на каждом слайде: перелистывая карусель, человек + // не должен терять из виду, что вещь/питомец уже вернулся к владельцу. + const foundText = postData?.category === CATEGORY_PET + ? t('postCard.petFoundStatus') + : t('postCard.foundStatus'); + const foundDate = isClosed ? formatFoundDate(getFoundAt(postData), i18n.language) : null; + const foundTitle = foundDate + ? t('postCard.foundStatusWithDate', { status: foundText, date: foundDate }) + : foundText; if (!urls.length) return null; @@ -81,11 +94,18 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
+ {isClosed && ( + + + {foundText} + + )} +
diff --git a/src/components/MapView.test.jsx b/src/components/MapView.test.jsx new file mode 100644 index 0000000..6e4cda1 --- /dev/null +++ b/src/components/MapView.test.jsx @@ -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( {}} />); + + // Границы появляются только после первого расчёта вьюпорта. + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + ymaps.publishBounds(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.queryByTestId("mapview-loading")).toBeNull(); + }); + expect(screen.queryByText("Ошибка загрузки карты")).toBeNull(); +}); + +test("даже если границы так и не пришли, карта не висит в вечной загрузке", async () => { + installFakeYmaps(); + + render( {}} />); + + await waitFor( + () => { + expect(screen.queryByTestId("mapview-loading")).toBeNull(); + }, + { timeout: 8000 } + ); +}); diff --git a/src/components/feed/FeedContent.jsx b/src/components/feed/FeedContent.jsx index 675d3db..69c3024 100644 --- a/src/components/feed/FeedContent.jsx +++ b/src/components/feed/FeedContent.jsx @@ -16,6 +16,7 @@ export default function FeedContent({ onEdit, onDelete, onMarkFound, + onReopen, onImageClick, onLocationClick, onAuthRequired, @@ -88,6 +89,7 @@ export default function FeedContent({ onEdit={activeNav === "myads" ? onEdit : undefined} onDelete={activeNav === "myads" ? onDelete : undefined} onMarkFound={activeNav === "myads" ? onMarkFound : undefined} + onReopen={activeNav === "myads" ? onReopen : undefined} isOwner={activeNav === "myads"} onImageClick={onImageClick} onLocationClick={onLocationClick} diff --git a/src/components/feed/FeedTopBar.tsx b/src/components/feed/FeedTopBar.tsx index 220fad6..030d5c8 100644 --- a/src/components/feed/FeedTopBar.tsx +++ b/src/components/feed/FeedTopBar.tsx @@ -18,6 +18,9 @@ interface FeedTopBarProps { onMenuClick: () => void; typeFilter: "loss" | "found" | null; onTypeFilterChange: (key: "loss" | "found" | null) => void; + /** Фильтр состояния: null — все, "active" — активные, "closed" — найденные. */ + statusFilter?: "active" | "closed" | null; + onStatusFilterChange?: (key: "active" | "closed" | null) => void; hidden?: boolean; /** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */ topBarRef?: Ref; @@ -37,6 +40,8 @@ export default function FeedTopBar({ onMenuClick, typeFilter, onTypeFilterChange, + statusFilter = null, + onStatusFilterChange, hidden = false, topBarRef, }: FeedTopBarProps) { @@ -51,6 +56,14 @@ export default function FeedTopBar({ { key: "found", label: t("feed.filterFound") }, ]; + // «Активные / Найденные» — второй ряд того же дропдауна: найденные + // объявления по умолчанию лишь прижаты к низу, а этими вкладками их можно + // убрать из ленты (или посмотреть только их). + const statusOptions: { key: "active" | "closed"; label: string }[] = [ + { key: "active", label: t("feed.filterActive") }, + { key: "closed", label: t("feed.filterClosed") }, + ]; + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -70,6 +83,12 @@ export default function FeedTopBar({ setOpen(false); }; + const handleSelectStatus = (key: "active" | "closed") => { + // Повторный тап по активной вкладке сбрасывает фильтр в «все». + onStatusFilterChange?.(statusFilter === key ? null : key); + setOpen(false); + }; + return (
@@ -142,6 +161,22 @@ export default function FeedTopBar({ {opt.label} ))} + {onStatusFilterChange && ( + <> +