Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 11s
Some checks failed
deploy-frontend / deploy (push) Failing after 11s
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import CheckIcon from "./icons/CheckIcon";
|
||||
import Toast from "./Toast";
|
||||
import { getImageUrl } from "../config/authConstants";
|
||||
import { CATEGORY_PET } from "../config/feedConstants";
|
||||
import { getFoundAt, formatFoundDate } from "../utils/foundAt";
|
||||
import { useContactSeller } from "../hooks/useContactSeller";
|
||||
import { useAuthContext } from "../context/AuthContext";
|
||||
|
||||
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const touchStart = useRef(null);
|
||||
const swiped = useRef(false);
|
||||
@@ -65,7 +68,17 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
|
||||
contactSeller(onClose);
|
||||
}, [contactSeller, onClose]);
|
||||
|
||||
const showContact = isAuthenticated && postData && !postData.isOwner && postData.status !== 'CLOSED';
|
||||
const isClosed = postData?.status === 'CLOSED';
|
||||
const showContact = isAuthenticated && postData && !postData.isOwner && !isClosed;
|
||||
// Статус «найдено» держим на каждом слайде: перелистывая карусель, человек
|
||||
// не должен терять из виду, что вещь/питомец уже вернулся к владельцу.
|
||||
const foundText = postData?.category === CATEGORY_PET
|
||||
? t('postCard.petFoundStatus')
|
||||
: t('postCard.foundStatus');
|
||||
const foundDate = isClosed ? formatFoundDate(getFoundAt(postData), i18n.language) : null;
|
||||
const foundTitle = foundDate
|
||||
? t('postCard.foundStatusWithDate', { status: foundText, date: foundDate })
|
||||
: foundText;
|
||||
|
||||
if (!urls.length) return null;
|
||||
|
||||
@@ -81,11 +94,18 @@ export default function ImageViewer({ images, initialIndex = 0, onClose, postDat
|
||||
</button>
|
||||
|
||||
<div className="f-viewer">
|
||||
{isClosed && (
|
||||
<span className="f-viewer-found-badge" title={String(foundTitle)}>
|
||||
<CheckIcon size={14}/>
|
||||
<span>{foundText}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
|
||||
<img
|
||||
src={urls[index]}
|
||||
alt=""
|
||||
className="f-viewer-img"
|
||||
className={`f-viewer-img${isClosed ? ' f-viewer-img-closed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
239
src/components/MapView.test.jsx
Normal file
239
src/components/MapView.test.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Регрессия: первая загрузка маркеров должна уходить с границами карты.
|
||||
*
|
||||
* Раньше первый запрос висел на map.events.add('load'), которого у ymaps.Map
|
||||
* в 2.1 нет — событие не наступало, fetch не уходил, а спиннер закрывал
|
||||
* карту навсегда. Фейковый ymaps ниже намеренно НЕ умеет в 'load'.
|
||||
*/
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import MapView from "./MapView";
|
||||
// Реальный i18n, а не заглушка: MapView использует t() и без инициализации
|
||||
// react-i18next сыплет предупреждение, а проверка текста ошибки теряла бы смысл.
|
||||
import "../i18n";
|
||||
|
||||
jest.mock("../hooks/useGeolocation", () => ({
|
||||
GEO_STATUS: {
|
||||
IDLE: "idle",
|
||||
LOADING: "loading",
|
||||
SUCCESS: "success",
|
||||
DENIED: "denied",
|
||||
UNSUPPORTED: "unsupported",
|
||||
TIMEOUT: "timeout",
|
||||
},
|
||||
// Геолокация в тестах не нужна и капризна (navigator.geolocation).
|
||||
useGeolocation: () => ({ coords: null, status: "idle", requestLocation: jest.fn() }),
|
||||
}));
|
||||
|
||||
const BOUNDS = [[55.7, 37.55], [55.8, 37.7]];
|
||||
|
||||
function installFakeYmaps({ boundsInitiallyNull = true } = {}) {
|
||||
const listeners = {};
|
||||
let bounds = boundsInitiallyNull ? null : BOUNDS;
|
||||
|
||||
const map = {
|
||||
events: {
|
||||
add(name, cb) {
|
||||
(listeners[name] = listeners[name] || []).push(cb);
|
||||
return { name, cb };
|
||||
},
|
||||
remove(sub) {
|
||||
listeners[sub.name] = (listeners[sub.name] || []).filter((cb) => cb !== sub.cb);
|
||||
},
|
||||
},
|
||||
geoObjects: { add() {}, removeAll() {} },
|
||||
getBounds: () => bounds,
|
||||
setCenter: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
window.ymaps = {
|
||||
ready: (cb) => cb(),
|
||||
Map: jest.fn(() => map),
|
||||
GeoObjectCollection: jest.fn(function GeoObjectCollection() {
|
||||
this.add = jest.fn();
|
||||
this.removeAll = jest.fn();
|
||||
}),
|
||||
Placemark: jest.fn(function Placemark() {
|
||||
this.events = { add() {} };
|
||||
this.geometry = { setCoordinates() {} };
|
||||
}),
|
||||
templateLayoutFactory: { createClass: jest.fn(() => function Layout() {}) },
|
||||
};
|
||||
|
||||
return {
|
||||
map,
|
||||
/** Имитирует первый расчёт вьюпорта — так ведёт себя настоящий API. */
|
||||
publishBounds() {
|
||||
bounds = BOUNDS;
|
||||
act(() => {
|
||||
(listeners.boundschange || []).forEach((cb) => cb());
|
||||
});
|
||||
},
|
||||
panTo(next) {
|
||||
bounds = next;
|
||||
act(() => {
|
||||
(listeners.boundschange || []).forEach((cb) => cb());
|
||||
});
|
||||
},
|
||||
fireBoundsChangeOnly() {
|
||||
act(() => {
|
||||
(listeners.boundschange || []).forEach((cb) => cb());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 1, latitude: 55.75, longitude: 37.61, type: "LOST", title: "Кошелёк" },
|
||||
],
|
||||
});
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
delete window.ymaps;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function mapCall(index = 0) {
|
||||
const url = fetchMock.mock.calls[index]?.[0] || "";
|
||||
return new URL(url, "http://localhost");
|
||||
}
|
||||
|
||||
test("первый запрос уходит с границами карты, а не пустым query", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView focusPostId={null} focusLat={NaN} focusLng={NaN} onBack={() => {}} />);
|
||||
|
||||
// Границы появляются только после первого расчёта вьюпорта.
|
||||
ymaps.publishBounds();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const url = mapCall();
|
||||
expect(url.pathname).toBe("/api/v1/posts/map");
|
||||
expect(url.searchParams.get("minLat")).toBe("55.7");
|
||||
expect(url.searchParams.get("minLng")).toBe("37.55");
|
||||
expect(url.searchParams.get("maxLat")).toBe("55.8");
|
||||
expect(url.searchParams.get("maxLng")).toBe("37.7");
|
||||
});
|
||||
|
||||
test("все четыре граничных параметра присутствуют в каждом запросе", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
const url = new URL(call[0], "http://localhost");
|
||||
for (const key of ["minLat", "maxLat", "minLng", "maxLng"]) {
|
||||
expect(url.searchParams.get(key)).not.toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("опциональные фильтры type/category уезжают в том же запросе", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView type="LOST" category="Электроника" onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const url = mapCall();
|
||||
expect(url.searchParams.get("type")).toBe("LOST");
|
||||
expect(url.searchParams.get("category")).toBe("Электроника");
|
||||
expect(url.searchParams.get("minLat")).toBe("55.7");
|
||||
});
|
||||
|
||||
test("фильтры не добавляются, когда не заданы", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const url = mapCall();
|
||||
expect(url.searchParams.has("type")).toBe(false);
|
||||
expect(url.searchParams.has("category")).toBe(false);
|
||||
});
|
||||
|
||||
test("перемещение карты перезапрашивает маркеры один раз, несмотря на debounce", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Серия boundschange без пауз — должна схлопнуться в один запрос.
|
||||
ymaps.fireBoundsChangeOnly();
|
||||
ymaps.fireBoundsChangeOnly();
|
||||
ymaps.fireBoundsChangeOnly();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Новые границы = ровно один новый запрос.
|
||||
ymaps.panTo([[55.9, 37.4], [56.0, 37.5]]);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const url = mapCall(1);
|
||||
expect(url.searchParams.get("minLat")).toBe("55.9");
|
||||
expect(url.searchParams.get("maxLng")).toBe("37.5");
|
||||
});
|
||||
|
||||
test("одинаковые границы не порождают повторный запрос", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
ymaps.fireBoundsChangeOnly();
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("спиннер снимается и карта не остаётся закрыта оверлеем", async () => {
|
||||
const ymaps = installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
ymaps.publishBounds();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mapview-loading")).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText("Ошибка загрузки карты")).toBeNull();
|
||||
});
|
||||
|
||||
test("даже если границы так и не пришли, карта не висит в вечной загрузке", async () => {
|
||||
installFakeYmaps();
|
||||
|
||||
render(<MapView onBack={() => {}} />);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByTestId("mapview-loading")).toBeNull();
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export default function FeedContent({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMarkFound,
|
||||
onReopen,
|
||||
onImageClick,
|
||||
onLocationClick,
|
||||
onAuthRequired,
|
||||
@@ -88,6 +89,7 @@ export default function FeedContent({
|
||||
onEdit={activeNav === "myads" ? onEdit : undefined}
|
||||
onDelete={activeNav === "myads" ? onDelete : undefined}
|
||||
onMarkFound={activeNav === "myads" ? onMarkFound : undefined}
|
||||
onReopen={activeNav === "myads" ? onReopen : undefined}
|
||||
isOwner={activeNav === "myads"}
|
||||
onImageClick={onImageClick}
|
||||
onLocationClick={onLocationClick}
|
||||
|
||||
@@ -18,6 +18,9 @@ interface FeedTopBarProps {
|
||||
onMenuClick: () => void;
|
||||
typeFilter: "loss" | "found" | null;
|
||||
onTypeFilterChange: (key: "loss" | "found" | null) => void;
|
||||
/** Фильтр состояния: null — все, "active" — активные, "closed" — найденные. */
|
||||
statusFilter?: "active" | "closed" | null;
|
||||
onStatusFilterChange?: (key: "active" | "closed" | null) => void;
|
||||
hidden?: boolean;
|
||||
/** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
|
||||
topBarRef?: Ref<HTMLDivElement>;
|
||||
@@ -37,6 +40,8 @@ export default function FeedTopBar({
|
||||
onMenuClick,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
statusFilter = null,
|
||||
onStatusFilterChange,
|
||||
hidden = false,
|
||||
topBarRef,
|
||||
}: FeedTopBarProps) {
|
||||
@@ -51,6 +56,14 @@ export default function FeedTopBar({
|
||||
{ key: "found", label: t("feed.filterFound") },
|
||||
];
|
||||
|
||||
// «Активные / Найденные» — второй ряд того же дропдауна: найденные
|
||||
// объявления по умолчанию лишь прижаты к низу, а этими вкладками их можно
|
||||
// убрать из ленты (или посмотреть только их).
|
||||
const statusOptions: { key: "active" | "closed"; label: string }[] = [
|
||||
{ key: "active", label: t("feed.filterActive") },
|
||||
{ key: "closed", label: t("feed.filterClosed") },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -70,6 +83,12 @@ export default function FeedTopBar({
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectStatus = (key: "active" | "closed") => {
|
||||
// Повторный тап по активной вкладке сбрасывает фильтр в «все».
|
||||
onStatusFilterChange?.(statusFilter === key ? null : key);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="f-topbar-dropdown-wrap">
|
||||
<div className={`f-topbar${hidden ? " f-topbar-hidden" : ""}`} ref={topBarRef}>
|
||||
@@ -142,6 +161,22 @@ export default function FeedTopBar({
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{onStatusFilterChange && (
|
||||
<>
|
||||
<span className="f-topbar-dropdown-sep" aria-hidden="true" />
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
aria-pressed={statusFilter === opt.key}
|
||||
className={`f-topbar-dropdown-item${statusFilter === opt.key ? " active" : ""}`}
|
||||
onClick={() => handleSelectStatus(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
8
src/components/icons/CheckIcon.jsx
Normal file
8
src/components/icons/CheckIcon.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function CheckIcon ({ size = 12 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user