Add Docker build and deploy pipeline
Some checks failed
deploy-frontend / deploy (push) Failing after 11s

This commit is contained in:
SlimusMinus
2026-10-07 23:27:38 +03:00
parent 6791ee6eb3
commit 6dc61050d9
23 changed files with 722 additions and 41 deletions

View File

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

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

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

View File

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

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