feat(tradein): версионирование продукта — единый источник, подвал, PDF, /versions #2824
20 changed files with 845 additions and 49 deletions
|
|
@ -30,11 +30,26 @@ jobs:
|
||||||
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
|
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
|
||||||
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
|
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
|
||||||
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
|
# в 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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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.
|
# 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.
|
# 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,
|
# 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 изменение обязано
|
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||||
- 'tradein-mvp/packages/scraper-kit/**'
|
- '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:
|
frontend:
|
||||||
- 'tradein-mvp/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:
|
browser:
|
||||||
- 'tradein-mvp/browser/**'
|
- 'tradein-mvp/browser/**'
|
||||||
infra:
|
infra:
|
||||||
|
|
@ -211,6 +238,13 @@ jobs:
|
||||||
context: ./tradein-mvp
|
context: ./tradein-mvp
|
||||||
file: ./tradein-mvp/backend/Dockerfile
|
file: ./tradein-mvp/backend/Dockerfile
|
||||||
push: true
|
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-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -236,6 +270,14 @@ jobs:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
- name: Build & push tradein-frontend
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -246,9 +288,15 @@ jobs:
|
||||||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||||
# уходит в notFound (404), не индексируется и не краулится.
|
# уходит в notFound (404), не индексируется и не краулится.
|
||||||
|
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||||
|
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||||
|
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||||
NEXT_PUBLIC_API_BASE_URL=/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-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
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/app /app/app
|
||||||
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
|
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
|
USER app
|
||||||
|
|
||||||
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
|
# 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/messages",
|
||||||
"/api/v1/trade-in/support/anon/unread",
|
"/api/v1/trade-in/support/anon/unread",
|
||||||
"/api/v1/trade-in/support/anon/read",
|
"/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) перед
|
# #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,
|
support,
|
||||||
team,
|
team,
|
||||||
trade_in,
|
trade_in,
|
||||||
|
version,
|
||||||
)
|
)
|
||||||
from app.core.auth_db import get_auth_engine
|
from app.core.auth_db import get_auth_engine
|
||||||
from app.core.config import settings
|
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(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
|
||||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
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(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(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(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
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 matplotlib.patches import Rectangle
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.version import product_version_line
|
||||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
|
||||||
_BORDER = _LINE
|
_BORDER = _LINE
|
||||||
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
|
_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/
|
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
|
||||||
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
||||||
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
|
_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")
|
"ДАТА", 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 (
|
return (
|
||||||
|
f"<div>"
|
||||||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
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"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"{mark_html}"
|
||||||
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
||||||
f"</div>"
|
f"</div>"
|
||||||
|
f"{version_html}"
|
||||||
|
f"</div>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -529,7 +542,11 @@ def _page_footer(
|
||||||
|
|
||||||
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
||||||
линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
|
линия-разделитель; строка 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'а (бренд/подзаголовок/№ страницы/дисклеймер
|
page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
|
||||||
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
||||||
|
|
@ -587,9 +604,6 @@ def _page_footer(
|
||||||
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
||||||
min-width:0;overflow-wrap:anywhere;">
|
min-width:0;overflow-wrap:anywhere;">
|
||||||
{_html.escape(brand.name).upper()}</span>
|
{_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>
|
</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=""
|
ARG NEXT_PUBLIC_ENABLE_PREVIEW=""
|
||||||
ENV NEXT_PUBLIC_ENABLE_PREVIEW=$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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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/public ./public
|
||||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
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
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
||||||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||||
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||||
import { pageBg } from "@/components/trade-in/v2/tokens";
|
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
|
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
||||||
// (no package.json change). Cyrillic + latin so RU labels render correctly.
|
// (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. */}
|
products without the МЕРА brand that don't need a support link. */}
|
||||||
<SupportButton />
|
<SupportButton />
|
||||||
</SupportChatProvider>
|
</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>
|
</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.
|
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||||
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
|
// 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
|
// 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 { tokens } from "./tokens";
|
||||||
import { version } from "./ui-config";
|
|
||||||
import type { Report } from "./types";
|
import type { Report } from "./types";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
|
|
@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) {
|
||||||
>
|
>
|
||||||
МЕРА
|
МЕРА
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
letterSpacing: "1px",
|
|
||||||
color: tokens.muted4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{version}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
|
|
||||||
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
|
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
// 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
|
// 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.
|
// 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 { useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
@ -13,7 +16,7 @@ import type { CSSProperties } from "react";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./ui-config";
|
import { navLabels } from "./ui-config";
|
||||||
import { useSupportChat } from "./SupportChatContext";
|
import { useSupportChat } from "./SupportChatContext";
|
||||||
|
|
||||||
// Real logged-in user identity, derived by the page from useMe()
|
// Real logged-in user identity, derived by the page from useMe()
|
||||||
|
|
@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = {
|
||||||
tokens.muted), что и остальные иконки этого дропдауна. */
|
tokens.muted), что и остальные иконки этого дропдауна. */
|
||||||
function UsersIcon() {
|
function UsersIcon() {
|
||||||
return (
|
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" />
|
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
|
||||||
<path
|
<path
|
||||||
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
|
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
|
||||||
stroke={tokens.muted}
|
stroke={tokens.muted}
|
||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
/>
|
/>
|
||||||
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
|
<path
|
||||||
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +146,7 @@ export default function TopNav({
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo + version */}
|
{/* Logo */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
|
||||||
<svg
|
<svg
|
||||||
width="184"
|
width="184"
|
||||||
|
|
@ -196,18 +213,6 @@ export default function TopNav({
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
|
|
@ -412,7 +417,13 @@ export default function TopNav({
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
title="Раздел «Профиль» скоро появится"
|
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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="5"
|
cy="5"
|
||||||
|
|
@ -430,13 +441,23 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
<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
|
<path
|
||||||
d="M3 1.5h6l3 3v9H3z"
|
d="M3 1.5h6l3 3v9H3z"
|
||||||
stroke={tokens.muted}
|
stroke={tokens.muted}
|
||||||
strokeWidth="1.2"
|
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>
|
</svg>
|
||||||
Мои отчёты{" "}
|
Мои отчёты{" "}
|
||||||
<span
|
<span
|
||||||
|
|
@ -458,7 +479,13 @@ export default function TopNav({
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
title="Раздел «Настройки» скоро появится"
|
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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="7.5"
|
cy="7.5"
|
||||||
|
|
@ -499,7 +526,13 @@ export default function TopNav({
|
||||||
openChat();
|
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
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
cy="7.5"
|
cy="7.5"
|
||||||
|
|
@ -561,7 +594,13 @@ export default function TopNav({
|
||||||
textAlign: "left",
|
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
|
<path
|
||||||
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
|
||||||
stroke="#cd6868"
|
stroke="#cd6868"
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,10 @@ export const overlayTitles: string[] = [
|
||||||
|
|
||||||
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
|
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