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
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:
parent
9d9457f67d
commit
8423af5dd5
20 changed files with 845 additions and 49 deletions
|
|
@ -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
46
tradein-mvp/CHANGELOG.md
Normal 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
1
tradein-mvp/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
2.1.0
|
||||
|
|
@ -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, а некоторые
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
20
tradein-mvp/backend/app/api/v1/version.py
Normal 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}
|
||||
|
|
@ -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) перед
|
||||
|
|
|
|||
83
tradein-mvp/backend/app/core/version.py
Normal file
83
tradein-mvp/backend/app/core/version.py
Normal 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()}"
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
99
tradein-mvp/backend/tests/test_version_api.py
Normal file
99
tradein-mvp/backend/tests/test_version_api.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
120
tradein-mvp/frontend/src/app/versions/page.tsx
Normal file
120
tradein-mvp/frontend/src/app/versions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
37
tradein-mvp/frontend/src/lib/buildInfo.ts
Normal file
37
tradein-mvp/frontend/src/lib/buildInfo.ts
Normal 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)}`;
|
||||
}
|
||||
106
tradein-mvp/frontend/src/lib/changelog.ts
Normal file
106
tradein-mvp/frontend/src/lib/changelog.ts
Normal 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;
|
||||
}
|
||||
34
tradein-mvp/frontend/src/types/version.ts
Normal file
34
tradein-mvp/frontend/src/types/version.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue