feat(tradein): версионирование продукта — единый источник, подвал, PDF, /versions (#2824)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 1m44s

This commit is contained in:
lekss361 2026-08-10 16:00:54 +00:00
parent 9d9457f67d
commit 8423af5dd5
20 changed files with 845 additions and 49 deletions

View file

@ -30,11 +30,26 @@ jobs:
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
app_version: ${{ steps.build-meta.outputs.app_version }}
build_sha: ${{ steps.build-meta.outputs.build_sha }}
build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
# проброшены как build-args в build-backend/build-frontend ниже (см.
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
# Считается ОДИН раз здесь, а не в каждой job отдельно.
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
id: build-meta
run: |
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
@ -107,8 +122,20 @@ jobs:
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
- 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
# причина, что у backend выше.
- 'tradein-mvp/VERSION'
# /versions статически запекает CHANGELOG.md в билд (см.
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
# frontend/** иначе не долетала бы до образа.
- 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
@ -211,6 +238,13 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
@ -236,6 +270,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
# поэтому копируем внутрь ДО build. /versions статически запекает его
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
# комментарий). Не влияет на кэш другого шага — читается только этим.
- name: Stage CHANGELOG.md into frontend build context
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
uses: docker/build-push-action@v6
with:
@ -246,9 +288,15 @@ jobs:
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |

46
tradein-mvp/CHANGELOG.md Normal file
View file

@ -0,0 +1,46 @@
# История версий «МЕРА»
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
## 2.1.0 — 2026-08-10
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
отчёты.
### Добавлено
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
уверенности в оценке.
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
аналогов и сделок, формирование выкупной стоимости.
- Аналитика по дому — история размещений объявлений и продаж в доме.
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
- Публичный лендинг «МЕРА».
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
страница истории версий.
### Изменено
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
раннего технического макета.
### Исправлено
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
комнатности не было варианта «Студия», из-за чего для студии подбирались
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
а над результатом показывается предупреждение о сниженной точности и о том,
какие параметры пришлось расширить.
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
адресов улица не распознавалась, и разделы оставались пустыми.
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
на обложке, некорректные допущения о сроке экспозиции.

1
tradein-mvp/VERSION Normal file
View file

@ -0,0 +1 @@
2.1.0

View file

@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
COPY --from=builder --chown=app:app /app/backend/app /app/app
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
COPY --chown=app:app VERSION VERSION
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
# (VERSION-файл выше / "dev" / момент импорта модуля).
#
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
ARG APP_VERSION=""
ARG BUILD_SHA=""
ARG BUILD_DATE=""
ENV APP_VERSION=$APP_VERSION \
BUILD_SHA=$BUILD_SHA \
BUILD_DATE=$BUILD_DATE
USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые

View file

@ -0,0 +1,20 @@
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
build date), source `app/core/version.py`.
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) это не
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
process env / уже загруженные при импорте константы (без похода в БД)."""
from __future__ import annotations
from fastapi import APIRouter
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
router = APIRouter()
@router.get("/version")
def get_version() -> dict[str, str]:
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}

View file

@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
# не секрет, читает только process env — быстрая справка для клиента/
# поддержки/смоук-теста, не должна требовать сессию.
"/api/v1/trade-in/version",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -0,0 +1,83 @@
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
запечённые в образ через build-args в `backend/Dockerfile`
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) там же
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
`date -u +%Y-%m-%dT%H:%M:%SZ`.
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата на момент импорта
модуля. Ничего здесь не должно падать при отсутствии env (потребитель
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
Номер версии НЕ дублируется больше нигде в коде читай `APP_VERSION` отсюда.
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
`@/lib/buildInfo` соответственно; не заводи третий.
"""
from __future__ import annotations
import datetime as dt
import os
from pathlib import Path
_DEFAULT_VERSION = "0.0.0"
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
_MAX_ANCESTORS = 6
def _find_version_file() -> Path | None:
here = Path(__file__).resolve()
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
candidate = ancestor / "VERSION"
if candidate.is_file():
return candidate
return None
def _read_version_file() -> str:
path = _find_version_file()
if path is None:
return _DEFAULT_VERSION
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
return _DEFAULT_VERSION
return text or _DEFAULT_VERSION
def _default_build_date() -> str:
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
# перечитывать на каждый запрос незачем.
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
колонтитул). Никогда не бросает исключение при неразборчивой строке
возвращает её как есть (это футер отчёта, не API-контракт)."""
try:
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return build_date
return parsed.strftime("%d.%m.%Y")
def product_version_line(product_name: str = "Мера") -> str:
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"

View file

@ -34,6 +34,7 @@ from app.api.v1 import (
support,
team,
trade_in,
version,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
@ -216,6 +217,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])

View file

@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
from matplotlib.patches import Rectangle
from app.core.config import settings
from app.core.version import product_version_line
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
_BORDER = _LINE
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
# HUD, а не версия PDF-модуля/API.
_REPORT_ENGINE_VERSION = "v2.0.6"
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
"ДАТА", report_date.strftime("%d.%m.%Y")
)
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
version_html = (
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
f"{_html.escape(product_version_line())}</div>"
)
return (
f"<div>"
f'<div style="display:flex;align-items:center;justify-content:space-between;'
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
f'padding-bottom:6pt;margin-bottom:9pt;">'
f'padding-bottom:6pt;margin-bottom:3pt;">'
f"{mark_html}"
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
f"</div>"
f"{version_html}"
f"</div>"
)
@ -529,7 +542,11 @@ def _page_footer(
строка 1 mono meta ( отчёта / дата / срок действия); тонкая градиентная
линия-разделитель; строка 2 точка акцента + wordmark (brand.name НЕ
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
НЕ дублируется единственное место вывода версии в PDF running-header
(_page_header product_version_line()); раньше рядом с wordmark висел
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
версией продукта расходился с header на каждой странице, снесён.
page_note старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
@ -587,9 +604,6 @@ def _page_footer(
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
min-width:0;overflow-wrap:anywhere;">
{_html.escape(brand.name).upper()}</span>
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
flex-shrink:0;white-space:nowrap;">
{_REPORT_ENGINE_VERSION}</span>
</div>
</div>
</div>

View file

@ -0,0 +1,99 @@
"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py +
app/api/v1/version.py.
Isolated FastAPI app (no full app.main import, no DB) same pattern as
tests/test_geocode_reverse_api.py: mount only the router under test.
"""
from __future__ import annotations
import importlib
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import version as version_module
from app.core import version as version_core
@pytest.fixture
def app() -> FastAPI:
application = FastAPI()
application.include_router(version_module.router, prefix="/api/v1/trade-in")
return application
# ── GET /api/v1/trade-in/version ─────────────────────────────────────────────
def test_version_endpoint_shape(app: FastAPI) -> None:
client = TestClient(app)
r = client.get("/api/v1/trade-in/version")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"version", "sha", "built_at"}
assert isinstance(body["version"], str) and body["version"]
assert isinstance(body["sha"], str) and body["sha"]
assert isinstance(body["built_at"], str) and body["built_at"]
def test_version_endpoint_matches_core_constants(app: FastAPI) -> None:
client = TestClient(app)
body = client.get("/api/v1/trade-in/version").json()
assert body["version"] == version_core.APP_VERSION
assert body["sha"] == version_core.BUILD_SHA
assert body["built_at"] == version_core.BUILD_DATE
def test_version_path_is_public_no_auth_required() -> None:
"""rbac_guard must let this path through without X-Authenticated-User /
session see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call."""
from app.core.rbac import _PUBLIC_PATHS
assert "/api/v1/trade-in/version" in _PUBLIC_PATHS
# ── app/core/version.py — product_version_line / format_build_date_human ────
def test_product_version_line_format() -> None:
line = version_core.product_version_line("Мера")
assert line.startswith("Мера v")
parts = line.split(" · ")
assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}"
def test_format_build_date_human_parses_iso_utc() -> None:
assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026"
def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None:
assert version_core.format_build_date_human("not-a-date") == "not-a-date"
# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ──────
# (local `uvicorn` run without a Docker build — see module docstring in
# app/core/version.py). Reloading the module re-executes its module-level
# env reads; nothing here may raise.
def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("APP_VERSION", raising=False)
monkeypatch.delenv("BUILD_SHA", raising=False)
monkeypatch.delenv("BUILD_DATE", raising=False)
reloaded = importlib.reload(version_core)
assert reloaded.BUILD_SHA == "dev"
assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default
assert reloaded.BUILD_DATE.endswith("Z")
# format/product helpers must still work off the fallback values (no crash).
assert reloaded.product_version_line("Мера").startswith("Мера v")
# Reload once more so any test running later in this process sees a module
# state consistent with whatever env pytest was actually invoked under.
importlib.reload(version_core)

View file

@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ENABLE_PREVIEW=""
ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть
# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические
# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не
# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник
# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml,
# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для
# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже
# frontend-код, потребляющий эти env (Dockerfile сам файл не читает).
ARG NEXT_PUBLIC_APP_VERSION=""
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ARG NEXT_PUBLIC_BUILD_SHA=""
ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA
ARG NEXT_PUBLIC_BUILD_DATE=""
ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE
# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на
# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI
# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно
# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job
# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с
# остальным контекстом. Локальная сборка без этого шага CI просто не находит
# файл — readChangelog() уже умеет деградировать (пустая история), сам
# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже).
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
@ -49,6 +72,13 @@ ENV NODE_ENV=production \
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
# /versions — Server Component, statically prerendered at `npm run build`
# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked
# into .next/standalone above. This is a defensive fallback ONLY, in case that
# page ever stops being static: glob (trailing `*`) makes it a no-op when the
# builder stage doesn't have the file either (local build without the CI
# pre-copy step, see builder stage comment above) — never fails the build.
COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./
USER node
EXPOSE 3000

View file

@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
import { pageBg } from "@/components/trade-in/v2/tokens";
import { VersionFooter } from "@/components/trade-in/VersionFooter";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
// (no package.json change). Cyrillic + latin so RU labels render correctly.
@ -53,6 +54,15 @@ export default function TradeInV2Layout({
products without the МЕРА brand that don't need a support link. */}
<SupportButton />
</SupportChatProvider>
{/* Real build-version indicator (task: показать реальную версию
продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider
it needs no chat context but still scoped to this /v2 layout for
the same reason SupportButton is: other basePath routes
(/scrapers/**, /sale-share) are unrelated products without the
МЕРА brand. Portals to document.body itself (see VersionFooter.tsx
docstring), so its position in this tree only matters for mount
order, not DOM placement. */}
<VersionFooter />
</div>
);
}

View file

@ -0,0 +1,120 @@
// /versions (→ `/trade-in/versions` behind basePath) — «История версий».
//
// Server Component, deliberately NOT "use client": `readChangelog()` reads
// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render
// time and gets statically embedded — no client-side fetch, no network hop
// (see src/lib/changelog.ts for the exact read/parse contract + a known
// build-context gap, flagged there).
//
// Auth: this route has NO guard of its own — it lives inside the same
// app-router segment as every other closed МЕРА page (history/, cache/,
// team/), so `app/layout.tsx`'s `<RouteGuard>` already gates it exactly
// like the rest of the product. No new RBAC path was added; whatever the
// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**`
// covers this page too.
import type { Metadata } from "next";
import Link from "next/link";
import "@/components/trade-in/trade-in.css";
import { APP_VERSION, formatRuDate } from "@/lib/buildInfo";
import { readChangelog } from "@/lib/changelog";
export const metadata: Metadata = {
title: "История версий — МЕРА",
};
export default function VersionsPage() {
const entries = readChangelog();
return (
<main className="page" style={{ maxWidth: 760, margin: "0 auto" }}>
<p style={{ marginBottom: 12 }}>
<Link href="/v2"> К оценке</Link>
</p>
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 4 }}>
История версий
</h1>
<p style={{ color: "var(--muted)", fontSize: 13, marginBottom: 24 }}>
Текущая версия:{" "}
{APP_VERSION === "dev" ? "dev-сборка" : `v${APP_VERSION}`}
</p>
{entries.length === 0 ? (
<p style={{ color: "var(--muted)" }}>
История изменений пока не опубликована.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{entries.map((entry) => {
const isCurrent = entry.version === APP_VERSION;
return (
<section key={`${entry.version}-${entry.date}`} className="card">
<div className="card-head">
<h2 style={{ display: "flex", alignItems: "center", gap: 8 }}>
v{entry.version}
{isCurrent && (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--accent-ink)",
background: "var(--accent-soft)",
borderRadius: 999,
padding: "2px 8px",
}}
>
текущая
</span>
)}
</h2>
<div className="card-meta">{formatRuDate(entry.date)}</div>
</div>
<div className="card-body">
{entry.sections.length === 0 ? (
<p style={{ color: "var(--muted)", fontSize: 13 }}>
Без описания изменений.
</p>
) : (
entry.sections.map((section) => (
<div key={section.title} style={{ marginBottom: 14 }}>
<h3
style={{
fontSize: 13,
fontWeight: 600,
marginBottom: 6,
color: "var(--fg-2)",
}}
>
{section.title}
</h3>
<ul
style={{
margin: 0,
paddingLeft: 20,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{section.items.map((item, i) => (
<li
key={`${section.title}-${i}`}
style={{ fontSize: 13.5, color: "var(--fg)" }}
>
{item}
</li>
))}
</ul>
</div>
))
)}
</div>
</section>
);
})}
</div>
)}
</main>
);
}

View file

@ -0,0 +1,80 @@
"use client";
// VersionFooter — small build-version indicator for the МЕРА product,
// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2
// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to
// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`)
// next to the МЕРА wordmark; both were removed (three independent "versions"
// on one screen, see PR review) — the wordmark stays in both places, just
// without a number attached. Values here come from build-time
// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no
// useEffect fetch.
//
// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is
// off-limits for this change), right next to `<SupportButton />`.
//
// Portaled to document.body — same reasoning/pattern as SupportButton.tsx:
// /v2 renders its HUD inside a fixed-size "artboard" that gets
// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a
// `position: fixed` descendant of a transformed ancestor is positioned
// relative to THAT ancestor, not the real viewport corner — portaling
// sidesteps that entirely, exactly like the support button already does.
import Link from "next/link";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { tokens } from "@/components/trade-in/v2/tokens";
import { formatVersionLabel } from "@/lib/buildInfo";
const styles = `
.version-footer{opacity:.72;transition:opacity .15s;}
.version-footer:hover{opacity:1;}
.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;}
.version-footer a:hover{color:${tokens.ink};}
@media (max-width: 480px){
.version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;}
}
`;
export function VersionFooter() {
// Portal-mount guard (SSR-safe): `document` only exists after mount
// (mirrors SupportButton.tsx / MapPicker.tsx).
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(
<>
<style>{styles}</style>
<div
className="version-footer"
style={{
position: "fixed",
left: 16,
bottom: 16,
// Below SupportButton (25) and every v2 HUD overlay — hides under
// modals/drawers instead of floating on top of them, mirrors the
// z-index reasoning documented in SupportButton.tsx.
zIndex: 24,
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 9px",
borderRadius: 999,
background: tokens.surface.w70,
border: `1px solid ${tokens.lineSoft2}`,
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: ".3px",
color: tokens.muted4,
whiteSpace: "nowrap",
}}
>
<span>{formatVersionLabel()}</span>
<Link href="/versions">История версий</Link>
</div>
</>,
document.body,
);
}

View file

@ -1,10 +1,13 @@
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
// report id / date / valid-until on the left, a decorative centre line, and the
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`.
// The trailing "v2.0.6" badge that used to sit next to the wordmark was a
// hardcoded literal (./ui-config `version`), independent of the real deployed
// build — removed. The real version is shown once, by `<VersionFooter />`
// (see app/v2/layout.tsx), not duplicated here.
import { tokens } from "./tokens";
import { version } from "./ui-config";
import type { Report } from "./types";
interface FooterProps {
@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) {
>
МЕРА
</span>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
letterSpacing: "1px",
color: tokens.muted4,
}}
>
{version}
</span>
</div>
</div>
);

View file

@ -2,10 +2,13 @@
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
// inline SVG logo + 5 nav tabs (active underline/triangle) + user menu.
// Tabs change only local UI state via onNavigate; the user dropdown owns its
// own useState. No data fetching — labels/version come from ./ui-config, the user
// own useState. No data fetching — labels come from ./ui-config, the user
// identity is fed in from the page (real useMe), colours from tokens.
// The build-version badge that used to sit next to the logo (hardcoded
// "v2.0.6") was removed — the real deployed version is shown once, by
// `<VersionFooter />` (app/v2/layout.tsx), not duplicated here.
import { useState } from "react";
import type { CSSProperties } from "react";
@ -13,7 +16,7 @@ import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./ui-config";
import { navLabels } from "./ui-config";
import { useSupportChat } from "./SupportChatContext";
// Real logged-in user identity, derived by the page from useMe()
@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = {
tokens.muted), что и остальные иконки этого дропдауна. */
function UsersIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M9.3 1.8a2.1 2.1 0 0 1 0 4"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path
d="M11 9.5c1.9.4 3 1.6 3 3.5"
stroke={tokens.muted}
strokeWidth="1.2"
/>
</svg>
);
}
@ -129,7 +146,7 @@ export default function TopNav({
flex: "0 0 auto",
}}
>
{/* Logo + version */}
{/* Logo */}
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
<svg
width="184"
@ -196,18 +213,6 @@ export default function TopNav({
</text>
</g>
</svg>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "9.5px",
letterSpacing: "1px",
color: tokens.muted2,
borderLeft: `1px solid ${tokens.line}`,
paddingLeft: "12px",
}}
>
{version}
</div>
</div>
{/* Nav tabs */}
@ -412,7 +417,13 @@ export default function TopNav({
aria-disabled="true"
title="Раздел «Профиль» скоро появится"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<circle
cx="7.5"
cy="5"
@ -430,13 +441,23 @@ export default function TopNav({
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<path
d="M3 1.5h6l3 3v9H3z"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9 1.5v3h3" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M9 1.5v3h3"
stroke={tokens.muted}
strokeWidth="1.2"
/>
</svg>
Мои отчёты{" "}
<span
@ -458,7 +479,13 @@ export default function TopNav({
aria-disabled="true"
title="Раздел «Настройки» скоро появится"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<circle
cx="7.5"
cy="7.5"
@ -499,7 +526,13 @@ export default function TopNav({
openChat();
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<circle
cx="7.5"
cy="7.5"
@ -561,7 +594,13 @@ export default function TopNav({
textAlign: "left",
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<path
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
stroke="#cd6868"

View file

@ -64,4 +64,10 @@ export const overlayTitles: string[] = [
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
export const version = "v2.0.6";
// `version` (hardcoded "v2.0.6") used to live here — TopNav.tsx + Footer.tsx
// both rendered it next to the МЕРА wordmark, independently of the real
// deployed build. Removed: the single source of truth for the displayed
// product version is now `@/lib/buildInfo` (APP_VERSION / formatVersionLabel,
// build-time NEXT_PUBLIC_* env baked in by frontend/Dockerfile), surfaced via
// `<VersionFooter />` (see app/v2/layout.tsx) — do not reintroduce a literal
// here.

View file

@ -0,0 +1,37 @@
// Build/version metadata for the МЕРА product — sourced from build-time
// `NEXT_PUBLIC_*` env vars, baked in by `frontend/Dockerfile` via
// `--build-arg` (see `.forgejo/workflows/deploy-tradein.yml`, build-frontend
// job). Safe to import from BOTH server and client components: Next.js
// inlines `NEXT_PUBLIC_*` references at build time into every bundle that
// references them — there is no runtime env lookup in the browser.
//
// Fallback "dev" — a local `npm run dev` / `next build` without the
// build-args set (i.e. every local run, and CI unless explicitly passed)
// reads honestly as "not a tagged deploy" instead of leaking "undefined"
// into the UI or crashing.
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
export const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA || "dev";
export const BUILD_DATE = process.env.NEXT_PUBLIC_BUILD_DATE || "";
/**
* ISO-8601 (`2026-08-10` or `2026-08-10T12:00:00Z`) `10.08.2026`.
*
* Deliberately a plain regex, not `Date#toLocaleDateString("ru-RU")`: the
* input is already a plain calendar date (no timezone conversion needed),
* and a regex keeps formatting identical between server-render and
* client-render without depending on locale data being present/consistent
* in whichever runtime executes it.
*/
export function formatRuDate(iso: string | undefined | null): string {
const match = iso ? /^(\d{4})-(\d{2})-(\d{2})/.exec(iso) : null;
if (!match) return "dev";
const [, year, month, day] = match;
return `${day}.${month}.${year}`;
}
/** "Мера v1.0.0 · a1b2c3d · 10.08.2026" (or "Мера dev · dev · dev" locally). */
export function formatVersionLabel(): string {
const versionPart = APP_VERSION === "dev" ? "dev" : `v${APP_VERSION}`;
return `Мера ${versionPart} · ${BUILD_SHA} · ${formatRuDate(BUILD_DATE)}`;
}

View file

@ -0,0 +1,106 @@
// Server-only: reads + parses `tradein-mvp/CHANGELOG.md` at build/request
// time via `fs.readFileSync` (statically evaluated by the /versions page,
// a Server Component — NEVER import this from a "use client" module, it
// would try to bundle `node:fs` for the browser).
//
// Build-context note: the frontend Docker build context is
// `tradein-mvp/frontend/` (see `.forgejo/workflows/deploy-tradein.yml`,
// build-frontend job), which by itself does NOT include
// `tradein-mvp/CHANGELOG.md` (one level above that context — Docker COPY
// cannot reach outside its context). Closed via a CI-side staging step
// (`Stage CHANGELOG.md into frontend build context` in that same workflow
// job, runs `cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md`
// right before `docker build`) — `frontend/Dockerfile`'s `COPY . .` then
// picks it up like any other context file, landing at the second candidate
// path below inside the image. `next build` run locally from a full
// checkout (no CI staging step) still finds it via the FIRST candidate path
// (one level up) instead. Either way `readChangelog()` degrades to an empty
// array (not a crash) if somehow neither path resolves.
import fs from "node:fs";
import path from "node:path";
import type { ChangelogEntry, ChangelogSection } from "@/types/version";
// Contract (Keep a Changelog, RU): version heading STRICTLY
// `## <semver> — <YYYY-MM-DD>`. Em dash (—) per contract; en dash/hyphen
// accepted too so a stray character in the dash doesn't silently blank the
// whole page.
const HEADING_RE = /^##\s+(\S+)\s+[—–-]\s+(\d{4}-\d{2}-\d{2})\s*$/;
const SECTION_RE = /^###\s+(.+?)\s*$/;
const LIST_ITEM_RE = /^[-*]\s+(.+)$/;
function candidatePaths(): string[] {
return [
// Contract path: repo-root-relative. `process.cwd()` during
// `next build`/`next dev` is `tradein-mvp/frontend`, so `..` is
// `tradein-mvp/`.
path.resolve(process.cwd(), "..", "CHANGELOG.md"),
// Fallback in case a future build-context fix copies it alongside the
// frontend package instead.
path.resolve(process.cwd(), "CHANGELOG.md"),
];
}
function readChangelogRaw(): string | null {
for (const candidate of candidatePaths()) {
try {
return fs.readFileSync(candidate, "utf-8");
} catch {
continue;
}
}
return null;
}
/**
* Parses CHANGELOG.md into structured entries, in file order (Keep a
* Changelog convention: newest on top). Never throws a missing or
* malformed file returns an empty array so both the page render and the
* build that statically embeds it degrade gracefully instead of failing.
*/
export function readChangelog(): ChangelogEntry[] {
const raw = readChangelogRaw();
if (!raw) return [];
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: ChangelogSection | null = null;
for (const rawLine of raw.split(/\r?\n/)) {
const heading = HEADING_RE.exec(rawLine);
if (heading) {
current = { version: heading[1], date: heading[2], sections: [] };
entries.push(current);
currentSection = null;
continue;
}
if (!current) continue; // preamble before the first version heading
const section = SECTION_RE.exec(rawLine);
if (section) {
currentSection = { title: section[1], items: [] };
current.sections.push(currentSection);
continue;
}
const trimmed = rawLine.trim();
const item = LIST_ITEM_RE.exec(trimmed);
if (item && currentSection) {
currentSection.items.push(item[1].trim());
continue;
}
// Soft-wrapped continuation of the previous list item — Markdown lets a
// bullet wrap across multiple lines with a leading indent (real
// CHANGELOG.md entries do this for anything longer than ~80 chars).
// Glue it onto the last item instead of silently truncating the bullet
// to its first line.
if (trimmed && currentSection && currentSection.items.length > 0) {
const lastIdx = currentSection.items.length - 1;
currentSection.items[lastIdx] =
`${currentSection.items[lastIdx]} ${trimmed}`;
}
}
return entries;
}

View file

@ -0,0 +1,34 @@
// Types for the trade-in version / changelog UI (VersionFooter + the
// /versions history page). See the contract this ships against:
// - `GET /api/v1/trade-in/version` → { version, sha, built_at }
// - `tradein-mvp/CHANGELOG.md` — Keep a Changelog (RU), version headings
// STRICTLY `## <semver> — <YYYY-MM-DD>`, sub-sections `### Добавлено` /
// `### Изменено` / `### Исправлено` with `- ` list items.
/**
* Response shape of `GET /api/v1/trade-in/version`.
*
* NOT currently fetched by the UI `VersionFooter` reads build-time
* `NEXT_PUBLIC_*` env vars instead (no runtime call, no useEffect fetch).
* Kept here as the documented contract for future callers (health checks,
* support tooling, etc.) that DO need to hit the endpoint.
*/
export interface VersionInfo {
version: string;
sha: string;
built_at: string; // ISO-8601
}
/** One `### <title>` sub-section inside a CHANGELOG.md version entry. */
export interface ChangelogSection {
title: string;
items: string[];
}
/** One `## <semver> — <YYYY-MM-DD>` version entry parsed from CHANGELOG.md. */
export interface ChangelogEntry {
version: string;
/** As written in the changelog heading, e.g. "2026-08-10" (no time part). */
date: string;
sections: ChangelogSection[];
}