chore(design-sync): durable-инпуты tradein DS-панели — 6 floor-card превью, бренд-шрифты Manrope/Plex, source-kit fork-фикс (#2267)

This commit is contained in:
bot-backend 2026-07-03 10:23:45 +03:00
parent 11861e5dc0
commit 375d59c48d
65 changed files with 1271 additions and 2 deletions

79
.design-sync/AUTHORING.md Normal file
View file

@ -0,0 +1,79 @@
# Preview authoring guide (design-sync, tradein-mvp-frontend)
You author `.design-sync/previews/<Name>.tsx` for assigned components so their preview cards
render real, styled, plausible compositions. The bundle is already built; you only recompile
your own previews.
## Import contract (IMPORTANT)
- Import the component from the package name: `import { OfferCard } from 'tradein-mvp-frontend';`
(the build maps this to the shipped bundle global — do NOT import from a relative src path).
- Import realistic data from the shared fixtures: `import { FIXTURE_ESTIMATE } from './_fixtures';`
- Type-only imports (`import type {...} from '@/types/trade-in'`) are erased — fine to use for casts.
- A provider is ALREADY configured globally (the app's `Providers` = TanStack Query). You do NOT
wrap previews in QueryClientProvider yourself (a second copy breaks context identity).
## Shared fixtures available from `./_fixtures` (realistic ЕКБ 2-к secondary)
- `FIXTURE_ESTIMATE: AggregatedEstimate` — median 9.85M ₽, range 9.110.6M, confidence high,
analogs[3], actual_deals[2], cian_valuation, avito_imv, dkp_corridor, price_trend[6]. Covers any
`estimate: AggregatedEstimate` prop.
- `FIXTURE_INPUT: TradeInEstimateInput` — the quartira params (area 55.3, 2 rooms, floor 5/11, monolith, good).
- `FIXTURE_IMV: IMVBenchmarkResponse` — for IMVBenchmark.
- `FIXTURE_HOUSES: HouseInfoForEstimate[]` — for HouseInfoCard.
- `FIXTURE_PLACEMENT: PlacementHistoryItem[]` — placement history rows.
- `FIXTURE_ANALYTICS: HouseAnalyticsResponse` — kpi + price_history[4] + recent_sold[2].
- `FIXTURE_SELLTIME: SellTimeSensitivityResponse` — exposure-vs-premium buckets.
- `FIXTURE_SALES: SalesVsListingsResponse` — street deals/listings pairs.
Read `tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx` — it shows EXACT prop usage for
many components (the canonical "hero" composition). Port it.
## How to find props
Read each component's source `tradein-mvp/frontend/src/components/<group>/<Name>.tsx`. The `interface Props`
(or inline destructure) is the contract. Many take `estimate`; some take other fixtures; some take
callbacks (pass `() => {}`); some fetch via hooks by id (see "fetch-coupled" below).
## Recipe
- 1 canonical story (the docs/page.tsx usage) named `Default`. Add 13 more ONLY if they visibly
differ (sweep a real variant axis: a state, an enum, empty-vs-full). Budget 14 cells.
- Realistic content only (the ЕКБ fixtures) — never foo/test.
- Callbacks → `() => {}`. Booleans like `isLoading`/`isPending` → usually `false` for the full state.
- Keep visually-identical variants OUT (e.g. a flag that only changes a link, not the view) — one cell.
## Fetch-coupled components (PlacementHistoryCard, HouseAnalyticsSection, PhotoUpload, ListingsCard
maybe) take an `estimateId` and fetch internally. The global Provider's query cache is EMPTY, so they
render their loading/empty state. If the component accepts data via props too, prefer that. If it can
ONLY fetch and renders blank/loading, author the best you can and RECORD it in your learnings file as
"renders loading/empty — fetch-coupled" — do NOT fake data you can't pass.
## Your loop (per component)
1. Read source → write `.design-sync/previews/<Name>.tsx` (named exports, no marker line).
2. Rebuild ONLY your components:
`node .ds-sync/lib/preview-rebuild.mjs --config .design-sync/config.json --node-modules tradein-mvp/frontend/node_modules --out ./ds-bundle --components <YOUR_COMMA_LIST>`
3. Capture ONLY your components:
`node .ds-sync/package-capture.mjs --out ./ds-bundle --components <YOUR_COMMA_LIST>`
4. READ each `ds-bundle/_screenshots/review/<group>__<Name>.png`. Grade each cell on the absolute
rubric: Styled (DS tokens/font visible) · Complete (renders whole, no missing children) · Plausible.
5. Write `.design-sync/.cache/review/<Name>.grade.json`:
`{"cells":{"<CellLabel>":{"verdict":"good"|"needs-work","note":"..."}}}` (keys = exact cell labels
from the capture log). `needs-work` → fix the .tsx, rebuild, recapture, regrade until `good`.
## HARD RULES (violating corrupts other agents' work)
- Edit ONLY your assigned `previews/<Name>.tsx`, your `.cache/review/<Name>.grade.json`, and your
`.design-sync/learnings/<BATCH>.md`. NOTHING else. Never touch config.json / NOTES.md / other previews.
- NEVER run `package-build.mjs` or `package-validate.mjs`. NEVER run `package-capture.mjs` without
`--components` (a full run prunes other agents' state). Only the two scoped commands above.
- If the SAME root cause hits 2+ of your components, or ANY config-level issue (a needed provider chain,
a missing token/css, a needed `cardMode`/`viewport` override, an import that won't resolve) → STOP on
those, record in your learnings file `.design-sync/learnings/<BATCH>.md`, and report to the orchestrator.
Config/NOTES changes are orchestrator-only.
## Wide/overlay components
Cards with `className="card"` are full-width and fine. If a card visibly overflows its grid cell or an
overlay (dialog/map modal) collapses, that needs a `cfg.overrides.<Name>.cardMode` change — you CANNOT
do that (config is orchestrator-only). Record it in learnings and move on.
## Calibration learnings from the solo set (HeroSummary, OfferCard, PriceRangeBar)
- The contract works: `import { X } from 'tradein-mvp-frontend'` + `./_fixtures` + global Provider.
- Cards render fully styled with the trade-in tokens/Manrope font.
- Components with analog photos show a grey placeholder (fixture photo_url=null, offline) — that is the
component's REAL no-photo state, grade it good (do not try to add external image URLs).
- Drop variants that don't change the view (brandSlug, isResubmitting rendered identical → single cell).

71
.design-sync/NOTES.md Normal file
View file

@ -0,0 +1,71 @@
# design-sync NOTES — tradein-mvp-frontend
Target: `tradein-mvp/frontend` (NOT the repo-root `frontend/`, which is the Site-Finder app). Project: gendesign-tradein.
## Build gotchas
- **No dist / no Storybook → synth-entry mode.** The package is a Next.js app, not a lib; `package.json` has no `main`/`module`/`exports`.
- **`--entry` must point at a NON-existent dist path** (`tradein-mvp/frontend/dist/index.js`). Two jobs:
1. PKG_DIR resolution walks up `dirname(--entry)` to the real named `package.json` → PKG_DIR=`tradein-mvp/frontend` (without `--entry`, PKG_DIR defaults to `node_modules/<pkg>` which doesn't self-install → ENOENT crash).
2. The path being absent makes `resolveDistEntry(soft)` return null → `synthEntry=true``deriveComponentsFromSrc` discovers components from `src/`. A path that EXISTS (e.g. package.json) suppresses synth mode → `[ZERO_MATCH]` tokens-only.
- **`cfg.*` path fields are PACKAGE-relative** (relative to PKG_DIR), not repo-relative. srcDir=`src`, cssEntry=`src/components/trade-in/trade-in.css`, tokensGlob=`src/app/globals.css`.
- `--node-modules tradein-mvp/frontend/node_modules` (react + @types/react live there).
## Styling
- Tokens in `src/app/globals.css` (`:root{--bg-app,--accent,…}`); component CSS in `src/components/trade-in/trade-in.css` (2277 lines). Manrope via remote Google Fonts `@import` (→ `[FONT_REMOTE]`, no action).
## Re-sync risks
- Synth scan over-includes non-component PascalCase exports (51 found vs ~37 files) — prune with `componentSrcMap: {Name: null}` as identified.
- Components are app-coupled (next/*, TanStack Query, data hooks); many need providers/mock props to render → expect floor cards / authored previews with composed props.
## Authoring (38 components, all authored-good)
- **Import contract:** preview imports `{X} from 'tradein-mvp-frontend'` (mapped to window.TradeInUI bundle) + data from `./_fixtures` (re-exports the repo's offline `app/ui-preview/estimate/fixture.ts`). Type imports erased.
- **Provider = seeded `PreviewProvider`** (`.design-sync/preview-provider.tsx`, wired via `cfg.extraEntries` + `cfg.provider`). Mirrors `app/ui-preview/estimate/page.tsx`'s seeded QueryClient: pre-fills `["auth","me"]` (fake admin user) + the estimate sub-queries (placement-history, house-analytics, sell-time-sensitivity, cian-price-changes=[], sales-vs-listings). This is what makes fetch-coupled cards (StreetDealsCard, HouseAnalyticsSection, PlacementHistoryCard) and auth-gated ones (RouteGuard, UserMenu) render offline.
- **CRITICAL:** an extraEntries module must NOT import anything that reads `process.env` at module top-level (e.g. `@/lib/useMe``@/lib/api`) — extraEntries evaluate BEFORE the synth-entry `.pkg-shim.mjs`, so `process` is undefined and the whole IIFE aborts (38/38 vanish from the global). The provider hardcodes `ME_QUERY_KEY = ["auth","me"]` instead of importing useMe.
- extraEntries path is PACKAGE-relative: `../../.design-sync/preview-provider.tsx`. Its `@/` aliases don't resolve from outside the tsconfig root → use relative `../tradein-mvp/frontend/src/...` for repo imports inside it.
- **Chart cards gate on analog count ≥8** (DistributionCard, ExposureCard): the shared fixture has 3 analogs → "not enough data" fallback. Those two previews extend `analogs` to 10 realistic ЕКБ lots INLINE in their own preview file (spread FIXTURE_ESTIMATE, override analogs+n_analogs) — never edit `_fixtures.ts`.
- **Admin/scraper panels** (DataQualitySection, ProviderProxySection, RunsTable, PacingSection, SystemHealthSection) fetch their own data with no repo fixture → they render their real styled LOADING/header state. Graded good as honest states; richer data would need admin fixtures that don't exist in the repo.
- **MapCard / MapPicker**: Leaflet from unpkg loads in the capture env; OSM raster tiles sometimes don't paint within the screenshot window (external tile fetch) — markers/popup/controls still render. Both graded good. MapPicker is a `createPortal` full-viewport overlay — rendered contained in capture; if a future capture viewport change makes it escape, add `cfg.overrides.MapPicker.cardMode`/`viewport`.
## Re-sync risks
- The `process` shim env defaults live in `.design-sync/overrides/source-kit.mjs` (synth entry). If the app reads NEW `process.env.NEXT_PUBLIC_*` vars, add them there or components throw.
- `PreviewProvider` seed keys are pinned to the fixture's `estimate_id` and address/area/rooms — if `fixture.ts` changes those, update `preview-provider.tsx` to match (else fetch-coupled cards re-floor).
- Synth scan re-includes Next route/layout/error files on every build → `componentSrcMap` nulls (13 entries) must persist. New app-router pages would need adding.
- `.d.ts` props are weak (`[key:string]:unknown`) — synth mode has no built types. Real prop contracts live in each component's source `interface Props`. A real `tsup`/`tsc` lib build would fix this (recommend if the agent needs strong API contracts).
- Grades clear on any `cfg.provider`/preview-affecting config change (expected) — re-grade from fresh sheets.
## Re-sync 2026-07-02 — v2 dashboard sync (main was 223 commits ahead; harness authored on June components)
**Two source-kit.mjs fork fixes were REQUIRED for the evolved v2 codebase (both committed in the override, declared in cfg.libOverrides):**
1. **Exclude `next/font` importers from the synth-entry.** `src/app/v2/layout.tsx` calls `Manrope()`/`IBM_Plex_Mono()` from `next/font/google` at MODULE TOP-LEVEL. esbuild can't resolve the Next-only loader → stubs it `(void 0)``undefined()` aborts the whole browser IIFE → `window.TradeInUI` empty → ALL 58 components vanish (`[RENDER] root empty` everywhere, `[BUNDLE_EXPORT]`). Fix: `comps` filter drops any file whose content matches `/from\s+['"]next\/font/`. If a NEW file top-level-calls a Next build-time loader, same class of crash — extend the filter.
2. **Re-export default-exported components.** `export * from <path>` does NOT re-export a module's `default`. The v2 views/nav/overlay/panel are authored `export default function <Name>` (AnalyticsView, HeroBar, HistoryView, ParamsPanel, SectionOverlay, SourcesView, TopNav) → absent from `window.TradeInUI``[BUNDLE_EXPORT] not a component`. Fix: entry now also emits `export { default as <Name> } from <path>` for each `export default function/class <Name>`. Named-export components (`export function X`) were always fine.
**componentSrcMap:** added `TradeInV2Layout / TradeInV2Page / SaleShareLayout / SaleSharePage` = null (Next route files, never DS components — same as RootLayout et al.).
**overrides (grid):** MapPicker `{cardMode:single, primaryStory:Default}` (portal/fixed), StreetDealsCard + Topbar `{cardMode:column}` (wider than a grid cell).
**Floor-card components (6, authorable on any future re-sync):** SaleShareControls, SaleShareList, SaleShareMap, SectionOverlay, LocationDrawer, BuildingListingsDrawer — overlays/drawers whose props don't seed rich data via PreviewProvider, so they show the honest typographic floor. All the OTHER v2 components (views/nav/hero/panel) render richly because the provider seeds their data.
**Known render warn:** ResultPanel — `variants identical` (Default vs Error cells render near-identically; not broken, the Error cell just doesn't diverge visually enough). Recorded here so re-syncs don't read it as new.
**Font note (re-sync risk):** the v2 HUD's real typefaces are **Manrope + IBM_Plex_Mono** (loaded by the excluded `v2/layout.tsx` via next/font → CSS vars `--font-manrope`/`--font-plex-mono`). `cfg.extraFonts` ships **Inter + JetBrains Mono** (June brand fonts). v2 previews therefore render Manrope-slot text in the shipped fallback. If brand-exact v2 rendering is wanted, add Manrope + IBM Plex Mono woff2 to `.design-sync/fonts/` + `cfg.extraFonts` and map the `--font-manrope`/`--font-plex-mono` vars.
**ResultPanel NAME COLLISION (fixed 2026-07-02):** two components named `ResultPanel` in src — `app/scrapers/_components/ScraperPage.tsx` (`export function ResultPanel`, scraper mut-panel) and `components/trade-in/v2/ResultPanel.tsx` (`export default function ResultPanel`, the v2 estimate result / honest hero). The default-re-export fix makes the v2 one win `window.TradeInUI.ResultPanel` (explicit `export {default as ResultPanel}` beats the star export). But the June authored preview `previews/ResultPanel.tsx` was the SCRAPER one (`mut` props) → the v2 component rendered fine (reads data from context) but its prompt.md documented the wrong (scraper) API. FIX: (1) `cfg.componentSrcMap.ResultPanel = "src/components/trade-in/v2/ResultPanel.tsx"` pins enrichment to v2; (2) re-authored `previews/ResultPanel.tsx``<ResultPanel onNavigate={()=>{}} />` (v2 API; `data` defaults to the component's built-in RESULT_FIXTURE = honest-hero). If a re-sync ever shows ResultPanel with scraper markup, the pin/preview regressed. Any NEW duplicate PascalCase component name will hit the same class of issue — the default-re-export makes the default-export win; pin + author the intended one.
**Upload gotcha (this machine):** after a follow-up single-component rebuild, `resync-verdict.json upload.deletePaths` came back with ALL other components (318 paths) — a stale-anchor diff artifact, NOT real deletions (ResultPanel + audit/uploads were absent from it). Do NOT feed that deletePaths to delete_files (would nuke the project). For a focused single-component re-upload: write just that component's `components/<group>/<Name>/*` + `_preview/<Name>.js` + `_ds_sync.json` (sentinel-fenced), deletes=[].
- **CORRECTION (2026-07-03):** the 318 deletePaths were NOT a diff artifact — they were REAL local deletions caused by a source-kit fork bug (see below): the July-02 `componentSrcMap.ResultPanel` pin collapsed the synth build to 1 component, so the local ds-bundle genuinely lost the other 53. The "don't feed deletePaths blindly" advice stands (it saved the project), but the root cause is fixed now.
## Re-sync 2026-07-03 — 6 floor cards authored + v2 brand fonts (#2267)
**source-kit.mjs fork fix #3 (REQUIRED): componentSrcMap pin must AUGMENT synth discovery, not replace it.** In synth mode there is no shipped `.d.ts`, so `exportedNames()` is empty and `names` contains ONLY the non-null `componentSrcMap` pins. The old guard `if (!components.length && synthEntry) components = deriveComponentsFromSrc(...)` therefore never ran once a single pin existed (ResultPanel, added 2026-07-02) → the whole bundle collapsed to 1 component (`(stale preview: X — component no longer exported)` for everything else; verdict shows all others as `removed` + bogus deletePaths). Fix in the fork: when `synthEntry`, always union `deriveComponentsFromSrc(srcFiles)` (minus `null`-excluded) with the pinned names. Any future pin would have re-triggered this. NB: the fork edit re-keys EVERY component's sourceKey → full re-grade pass (done: 47/54 renderHashes byte-identical to the 2026-07-02 anchor → carry-forward grades; the rest eyeballed).
**6 floor cards → authored (all graded good):**
- `SaleShareControls` — inline `SaleShareSummary` (histogram 7 корзин, coverage 92.1%); порог 8% приглушает нижнюю корзину; все фильтры.
- `SaleShareList` — 6 инлайн-домов ЕКБ (heat-бейджи, «аварийный», over_100 «возможно, несколько корпусов», selected-row) + Empty cell. `cardMode: column`.
- `SaleShareMap` — 8 heat-маркеров + открытый popup выбранного дома; OSM-тайлы офлайн не красятся (известно, как MapCard).
- `SectionOverlay` (v2) — рендерится contained в relative-«артборде» (absolute-позиционирование против ближайшего positioned ancestor; wrapper height 680 + v2-градиент). 2 cells: HistoryView (04) / AnalyticsView (06) на их встроенных fixtures (data-props не переданы). `cardMode: column`.
- `LocationDrawer` (v2) — open=true в таком же contained-артборде (height 640). `cardMode: column`.
- `BuildingListingsDrawer``createPortal` + `.ss-drawer-overlay` = position:fixed → `cardMode: single` (прецедент MapPicker). Шапка богатая (props), тело fetch-coupled (`useBuildingListings`, data-prop нет) → офлайн честный error-state «Не удалось загрузить объявления дома». Сидировать можно было бы ключом `["sale-share","listings",<house_id>]` в PreviewProvider — сознательно НЕ сделано (кэш-ключ завязан на house_id фикстуры превью; хрупко).
**v2 brand fonts shipped (Manrope + IBM Plex Mono).** woff2 (latin+cyrillic; Manrope variable 200-800, Plex Mono static 300/400/500 — веса из `app/v2/layout.tsx`) скачаны с Google Fonts → `.design-sync/fonts/`, @font-face добавлены в `brand-fonts.css`. **ГОЧА: extraFonts-пайплайн (`css.mjs extractFonts`) извлекает ТОЛЬКО `@font-face`-блоки — `:root{}` из brand-fonts.css молча выбрасывается.** Маппинг `--font-manrope`/`--font-plex-mono` поэтому живёт в `preview-provider.tsx` (`<style>` в провайдере — капчер-путь) + задокументирован для design-консюмеров в `conventions.md` (сниппет `:root{...}`). После фикса v2-цифры реально в Plex Mono (проверено по sheets ResultPanel/SectionOverlay).
**Upload 2026-07-03: НЕ выполнен из worker-сессии** — DesignSync MCP-тулов в ней нет (ToolSearch пуст). Verdict готов и чист: ok=true, pendingGrade=0, deletePaths=0 (легитимно — remote-анкор полный, local снова 54 компонента), upload.any=true, components=54 (все re-key'нуты форк-фиксом), bundle+styling+aux=true. Main-сессия: залить ds-bundle по verdict'у (deletePaths пуст — ничего не удалять) и после успеха скопировать свежий `ds-bundle/_ds_sync.json``.design-sync/.cache/remote-sync.json` (новый анкор).

53
.design-sync/config.json Normal file
View file

@ -0,0 +1,53 @@
{
"projectId": "e4b235af-089b-4532-8025-5199a2695a12",
"shape": "package",
"pkg": "tradein-mvp-frontend",
"globalName": "TradeInUI",
"srcDir": "src",
"tsconfig": "tsconfig.json",
"cssEntry": "src/components/trade-in/trade-in.css",
"tokensGlob": "src/app/globals.css",
"buildCmd": "node .ds-sync/package-build.mjs --config .design-sync/config.json --node-modules tradein-mvp/frontend/node_modules --entry tradein-mvp/frontend/dist/index.js --out ./ds-bundle",
"libOverrides": {
"source-kit.mjs": "synth-entry process shim for Next.js app (process.env.NEXT_PUBLIC_* reads)"
},
"componentSrcMap": {
"Error": null,
"GlobalError": null,
"Providers": null,
"RootLayout": null,
"AvitoScraperPage": null,
"CachePage": null,
"CianScraperPage": null,
"HistoryPage": null,
"PreviewEstimatePage": null,
"ScraperPage": null,
"ScrapersUnifiedPage": null,
"TradeInPage": null,
"YandexScraperPage": null,
"TradeInV2Layout": null,
"TradeInV2Page": null,
"SaleShareLayout": null,
"SaleSharePage": null,
"ResultPanel": "src/components/trade-in/v2/ResultPanel.tsx"
},
"overrides": {
"MapPicker": { "cardMode": "single", "primaryStory": "Default" },
"StreetDealsCard": { "cardMode": "column" },
"Topbar": { "cardMode": "column" },
"SaleShareList": { "cardMode": "column" },
"SectionOverlay": { "cardMode": "column" },
"LocationDrawer": { "cardMode": "column" },
"BuildingListingsDrawer": { "cardMode": "single", "primaryStory": "Default" }
},
"provider": {
"component": "PreviewProvider"
},
"extraEntries": [
"../../.design-sync/preview-provider.tsx"
],
"readmeHeader": ".design-sync/conventions.md",
"extraFonts": [
"../../.design-sync/fonts/brand-fonts.css"
]
}

View file

@ -0,0 +1,56 @@
# Trade-In UI — how to build with this design system
React components from the GenDesign **trade-in** product (real-estate trade-in valuation, RU/ЕКБ). Import everything from `tradein-mvp-frontend` (bound at `window.TradeInUI`). The cards are domain-specific and **prop-driven** — you compose them with data, you don't restyle their internals.
## Setup & wrapping (required for data components)
Most cards take their data as **props** and render standalone. But several read app state through **TanStack Query** hooks (`UserMenu`, `Topbar`, `RouteGuard``useMe`; `HouseAnalyticsSection`, `PlacementHistoryCard`, `StreetDealsCard` → estimate sub-queries). Those MUST be rendered inside a `QueryClientProvider` (the app ships one as `Providers`). Without it they throw "No QueryClient set"; with an empty client they render their loading/null state. Prop-only cards (`HeroSummary`, `OfferCard`, `PriceRangeBar`, `DealsCard`, `ListingsCard`, charts…) need no provider.
```tsx
import { Providers, HeroSummary, OfferCard } from "tradein-mvp-frontend";
<Providers>
<main className="page" style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<HeroSummary estimate={estimate} input={input} onResubmit={fn} />
<OfferCard estimate={estimate} brandSlug={null} />
</main>
</Providers>
```
Load `styles.css` once at the root — it pulls the component CSS + design tokens.
## Styling idiom — global stylesheet + CSS custom properties
This is **not Tailwind and not CSS-in-JS**. Styling is a **global stylesheet** of semantic class names plus **CSS custom-property design tokens**. Two rules:
1. **The components carry their own classes** (`.card`, `.card-head`, `.section-kicker`, `.count-strip`, `.pricebar`, `.source-chip`, `.control`, `.pill`, `.mono`, `.top-nav`, `.meta-grid`…). Don't reach inside them; compose via props. New class names you invent will not exist in the stylesheet.
2. **For your own layout/wrapper glue, use the token vars + inline styles**, on the 4/8/12/16/24/32 spacing scale (tabular-nums for numbers). Color/shape tokens, all defined in the shipped CSS:
| Group | Tokens |
|---|---|
| Surface | `--bg-app` `--bg-card` `--bg-card-alt` `--bg-headline` |
| Text | `--fg-primary` `--fg-secondary` `--fg-tertiary` `--fg-on-dark` |
| Brand/CTA | `--accent` `--accent-hover` `--accent-soft` `--accent-2` |
| Semantic | `--success` `--warn` `--danger` (`*-soft` variants) |
| Border | `--border-soft` `--border-card` `--border-strong` |
| Shape/type | `--radius` `--radius-sm` `--radius-lg` `--shadow-md` `--font-sans` (Manrope) `--font-mono` `--container` |
**v2 (МЕРА HUD) fonts:** the `v2/*` components read `var(--font-manrope)` / `var(--font-plex-mono)` (in the app these come from `next/font`). The woff2 for both families ships in `fonts/fonts.css`; define the vars once at your design root:
```css
:root { --font-manrope: 'Manrope'; --font-plex-mono: 'IBM Plex Mono'; }
```
```tsx
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border-card)",
borderRadius: "var(--radius)", padding: 16, color: "var(--fg-primary)",
fontFamily: "var(--font-sans)" }}>
<b style={{ color: "var(--accent)" }}>9 850 000 ₽</b>
</div>
```
## Where the truth is
- **Styles/tokens:** read the bound `styles.css` and its `@import` `_ds_bundle.css` — the authoritative class + token list.
- **Per component:** `<Name>.d.ts` (props) and `<Name>.prompt.md` (usage). NB: synth-built `.d.ts` props are loose (`[key: string]: unknown`); the `.prompt.md` + preview card show real prop shapes and realistic data.
- Money/area/dates are RU-formatted (`toLocaleString("ru-RU")`, `₽`, `м²`). Keep that idiom.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,112 @@
/* Brand fonts shipped with the trade-in DS bundle.
* June set: Inter + JetBrains Mono (variable woff2).
* v2 HUD set (2026-07-03): Manrope + IBM Plex Mono the МЕРА v2 typefaces,
* loaded in the app via next/font in app/v2/layout.tsx (CSS vars
* --font-manrope / --font-plex-mono). next/font is excluded from the synth
* bundle, so we ship the woff2 here and map the vars in :root below.
* All fonts: latin + cyrillic subsets (ЕКБ addresses are Cyrillic). */
:root {
--font-manrope: 'Manrope';
--font-plex-mono: 'IBM Plex Mono';
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('./Manrope-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('./Manrope-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./IBMPlexMono-300-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./IBMPlexMono-300-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IBMPlexMono-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IBMPlexMono-400-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./IBMPlexMono-500-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./IBMPlexMono-500-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('./Inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('./Inter-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('./JetBrainsMono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('./JetBrainsMono-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

View file

@ -0,0 +1,213 @@
// Non-storybook `package` adapter. Bundles dist/ when present (the authoritative
// component list comes from shipped .d.ts; with no dist it synthesizes an
// entry from src/ as a last resort) and opportunistically enriches each
// component from src/ — JSDoc and dir-derived group. Every enrichment miss
// degrades to the plain-dist behaviour.
//
// Discovery is heuristic-based; each heuristic has a `.design-sync/config.json`
// override (ASSUMPTION comments below name them) so repos that don't match the
// defaults write config, not code. `componentSrcMap` is the single override
// knob for component inclusion: non-null value = add/pin src path, null =
// exclude a .d.ts-exported internal.
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { Project, Node, ts } from 'ts-morph';
// forked from design-sync lib/source-kit.mjs — synth-entry process shim (Next.js app reads process.env.NEXT_PUBLIC_*)
import { leadingJsdoc, readText, slash, walk } from '../../.ds-sync/lib/common.mjs';
import { resolveDistEntry } from '../../.ds-sync/lib/bundle.mjs';
import { exportedNames, isComponentName } from '../../.ds-sync/lib/dts.mjs';
const NON_IMPL_RX = /\.(stories|test|spec)\./;
const SRC_IMPL_RX = /\.(tsx|jsx)$/;
// Dir names that don't usefully group components — skip so the emitted path
// is `components/<group>/<Name>` not `components/components/<Name>`.
const GENERIC_DIR = new Set(['components', 'component', 'src', 'lib', 'ui', 'packages', 'react']);
const slug = (s) => s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'general';
// No .d.ts → scan src files for PascalCase value exports via ts-morph.
function deriveComponentsFromSrc(srcFiles) {
const project = new Project({
skipAddingFilesFromTsConfig: true,
compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true, skipLibCheck: true },
});
const seen = new Set();
for (const p of srcFiles) {
if (NON_IMPL_RX.test(p) || !SRC_IMPL_RX.test(p)) continue;
const sf = project.addSourceFileAtPathIfExists(p);
if (!sf) continue;
for (const [name, decls] of sf.getExportedDeclarations()) {
// `export default function Button()` is keyed as 'default' — recover
// the declared name from the function/class node.
const real = name === 'default'
? decls.map((d) => d.getName?.()).find((n) => n && n !== 'default')
: name;
if (!real || !/^[A-Z][A-Za-z0-9]*$/.test(real)) continue;
if (decls.some((d) => Node.isVariableDeclaration(d) || Node.isFunctionDeclaration(d) || Node.isClassDeclaration(d))) {
seen.add(real);
}
}
}
return [...seen].sort().map((name) => ({ name, group: 'general' }));
}
export async function resolvePackage(ctx) {
const { PKG_DIR, pkgJson, ENTRY_OVERRIDE, PKG, OUT, cfg } = ctx;
const srcMap = cfg.componentSrcMap ?? {};
// ── 1. src/ discovery (best-effort; feeds enrichment + synth-entry fallback).
// ASSUMPTION: source root is first of src/ | lib/ | components/. Override: cfg.srcDir.
const srcRoot = [cfg.srcDir, 'src', 'lib', 'components']
.map((d) => d && resolve(PKG_DIR, d))
.find((d) => d && existsSync(d));
const srcFiles = srcRoot ? walk(srcRoot, (n) => /\.(tsx|jsx|mdx?)$/.test(n)) : [];
// ── 2. entry: dist if it exists, else synthesize from src/ (last resort).
let entry = resolveDistEntry({ pkgDir: PKG_DIR, pkgJson, override: ENTRY_OVERRIDE, pkgName: PKG, soft: true });
let synthEntry = false;
if (!entry) {
if (!srcRoot) {
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
process.exit(1);
}
// Next route files (app/**/layout.tsx, page.tsx) that call `next/font`
// loaders (Manrope(), IBM_Plex_Mono()) at module top-level crash the browser
// IIFE: esbuild can't resolve the Next-only loader → stubs it to `undefined`
// → `undefined()` aborts the whole bundle → window.<global> stays empty and
// every component vanishes. These files are never DS components anyway, so
// drop any `next/font`-importing module from the synth-entry set.
const comps = srcFiles.filter(
(p) =>
SRC_IMPL_RX.test(p) &&
!NON_IMPL_RX.test(p) &&
!/from\s+['"]next\/font/.test(readFileSync(p, 'utf8')),
);
// Next.js app code reads process.env.NEXT_PUBLIC_* at module top-level; in
// the browser IIFE `process` is undefined → every component throws. Emit a
// shim module and import it FIRST (ESM evaluates the first import's body
// before the re-exported component modules), so process exists before any
// component body runs.
const shim = resolve(OUT, '.pkg-shim.mjs');
writeFileSync(
shim,
'globalThis.process ??= { env: {} };\n' +
'globalThis.process.env ??= {};\n' +
'const __e = globalThis.process.env;\n' +
"__e.NODE_ENV ??= 'development';\n" +
"__e.NEXT_PUBLIC_ENABLE_PREVIEW ??= '1';\n" +
"__e.NEXT_PUBLIC_BASE_PATH ??= '';\n" +
"__e.NEXT_PUBLIC_API_BASE_URL ??= '';\n" +
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
);
entry = join(OUT, '.pkg-entry.mjs');
// `export *` does NOT re-export a module's default. Components authored as
// `export default function <Name>` (the v2 views/nav/overlay/panel) would be
// absent from window.<global> → [BUNDLE_EXPORT] "not a component". Re-export
// each named default under its declared name so default-exported components
// reach the global alongside the named ones.
const defaultReexports = comps
.map((p) => {
const m = readFileSync(p, 'utf8').match(
/export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9]*)/,
);
return m ? `export { default as ${m[1]} } from ${JSON.stringify(p)};` : null;
})
.filter(Boolean);
writeFileSync(
entry,
`import ${JSON.stringify(slash(shim))};\n` +
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
'\n' +
defaultReexports.join('\n') +
'\n',
);
synthEntry = true;
console.error(
`[NO_DIST] no built entry — synthesizing from ${comps.length} src files (run the package's build for best results)`,
);
}
// ── 3. component list: from shipped .d.ts (authoritative when dist exists).
// ASSUMPTION: components = PascalCase value exports in the .d.ts tree.
// Override: cfg.componentSrcMap (non-null adds/pins, null excludes).
const exported = exportedNames(PKG_DIR, pkgJson);
const names = new Set([...exported].filter(isComponentName));
for (const [k, v] of Object.entries(srcMap)) {
if (v === null) { names.delete(k); continue; }
// Names reach `<script>` blocks in the emitted HTML — reject anything
// that isn't a plain PascalCase identifier.
if (!/^[A-Z][A-Za-z0-9]*$/.test(k)) {
console.error(`[CONFIG] componentSrcMap: "${k}" is not a valid component name (PascalCase identifiers only)`);
continue;
}
names.add(k);
}
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
if (synthEntry) {
// Synth mode has no shipped .d.ts → `names` holds only componentSrcMap
// pins. A non-null pin must AUGMENT src discovery (it exists to pin
// enrichment to a specific file), not REPLACE it — with the old
// `!components.length` guard a single pin (ResultPanel, 2026-07-02)
// collapsed the whole synth bundle to just the pinned component.
const derived = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
const have = new Set(components.map((c) => c.name));
components = components
.concat(derived.filter((c) => !have.has(c.name)))
.sort((a, b) => a.name.localeCompare(b.name));
}
if (!components.length) {
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
console.error('[ZERO_MATCH] no component exports — treating as tokens-only DS');
return { shape: 'package', entry, components: [], tokensOnly: true };
}
console.error(`[ZERO_MATCH] no PascalCase exports in ${PKG} and no styles — nothing to sync`);
process.exit(1);
}
// ── 4. src/ enrichment per component. Every miss degrades to plain-dist.
if (srcRoot) {
for (const c of components) {
// Pinned via config → skip fuzzy-find entirely.
let hit = typeof srcMap[c.name] === 'string' ? slash(resolve(PKG_DIR, srcMap[c.name])) : null;
if (!hit) {
// ASSUMPTION: <Name>.tsx | <name>/<name>.tsx | <Name>/index.tsx |
// <kebab-name>.tsx, case-insensitive; dir-match ranks above
// bare-file match, then prefer one that actually exports `c.name`.
// Override: cfg.componentSrcMap.
const kebab = c.name.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
const nameRx = new RegExp(
`(?:^|/)(?:${c.name}/(?:index|${c.name})\\.(tsx|jsx)|(?:${c.name}|${kebab})\\.(tsx|jsx))$`,
'i',
);
const hits = srcFiles
.filter((p) => nameRx.test(p) && !NON_IMPL_RX.test(p))
.sort(
(a, b) =>
(b.toLowerCase().includes(`/${c.name.toLowerCase()}/`) ? 1 : 0) -
(a.toLowerCase().includes(`/${c.name.toLowerCase()}/`) ? 1 : 0),
);
const exportRx = new RegExp(`export\\s+(?:default\\s+)?(?:const|let|var|function|class)\\s+${c.name}\\b`);
hit = hits.find((p) => exportRx.test(readText(p))) ?? hits[0];
}
if (!hit || !existsSync(hit)) continue;
c.srcPath = hit;
c.doc = leadingJsdoc(readText(hit), c.name) || undefined;
// group = last src/ path segment that isn't the component's own dir or
// a generic container name — else JSDoc @category — else 'general'.
c.group = slug(
slash(relative(srcRoot, dirname(hit)))
.split('/')
.filter((s) => s && s.toLowerCase() !== c.name.toLowerCase() && !GENERIC_DIR.has(s.toLowerCase()))
.at(-1)
|| (c.doc && /@category\s+(\S+)/.exec(c.doc)?.[1])
|| 'general',
);
}
}
console.error(
` package: ${components.length} components` +
(srcRoot ? ` (${components.filter((c) => c.srcPath).length} src-matched)` : ' (no src/ — dist-only)'),
);
return { shape: 'package', entry, components, synthEntry, exported };
}

View file

@ -0,0 +1,73 @@
"use client";
// Seeded provider for design-sync preview cards. Mirrors the repo's own offline
// preview client (tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx):
// a QueryClient pre-filled with the FIXTURE_* data + a fake authorized user, so
// fetch-coupled cards (HouseAnalyticsSection, PlacementHistoryCard, StreetDealsCard)
// and auth-gated ones (RouteGuard, UserMenu) render offline. Bundled into
// window.<GLOBAL> via cfg.extraEntries → shares the SAME react-query instance as
// the components (context identity), which a second copy in a preview would break.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import {
FIXTURE_ANALYTICS,
FIXTURE_ESTIMATE,
FIXTURE_PLACEMENT,
FIXTURE_SALES,
FIXTURE_SELLTIME,
} from "../tradein-mvp/frontend/src/app/ui-preview/estimate/fixture";
// Inlined to avoid importing @/lib/useMe → @/lib/api, which reads process.env
// at module top-level; in an extraEntries module that evaluates before the
// synth-entry process shim and aborts the whole bundle. Key must match
// useMe.ts's ME_QUERY_KEY exactly.
const ME_QUERY_KEY = ["auth", "me"] as const;
const PREVIEW_ID = FIXTURE_ESTIMATE.estimate_id;
const PREVIEW_USER = {
username: "preview",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
brand: null,
};
export function PreviewProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity, refetchOnWindowFocus: false },
},
});
qc.setQueryData(ME_QUERY_KEY, PREVIEW_USER);
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "placement-history"], FIXTURE_PLACEMENT);
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "house-analytics"], FIXTURE_ANALYTICS);
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "sell-time-sensitivity"], FIXTURE_SELLTIME);
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "cian-price-changes"], []);
qc.setQueryData(
[
"trade-in",
"sales-vs-listings",
FIXTURE_ESTIMATE.target_address,
FIXTURE_ESTIMATE.area_m2,
FIXTURE_ESTIMATE.rooms,
],
FIXTURE_SALES,
);
return qc;
});
return (
<QueryClientProvider client={client}>
{/* v2 HUD font vars. The app defines --font-manrope/--font-plex-mono via
next/font in app/v2/layout.tsx, which is excluded from the synth
bundle (next/font loader crash) so the vars never exist in capture
and tokens.font.* fell back. The @font-face for both families ships
in .design-sync/fonts/brand-fonts.css (cfg.extraFonts), but that
pipeline extracts @font-face blocks ONLY (a :root{} there is
dropped) map the vars here instead. */}
<style>{":root{--font-manrope:'Manrope';--font-plex-mono:'IBM Plex Mono'}"}</style>
{children}
</QueryClientProvider>
);
}

View file

@ -0,0 +1,12 @@
import { AddressInput } from 'tradein-mvp-frontend';
/** Combobox адреса ЕКБ с автокомплитом. Dropdown открывается по focus/набору
* (фетч suggest офлайн недоступен) карточка показывает заполненный control. */
export const Default = () => (
<AddressInput
value="Екатеринбург, ул. Репина, 75/2"
onChange={() => {}}
onPickCoords={() => {}}
placeholder="ул. Малышева, 30 · Куйбышева, 48…"
/>
);

View file

@ -0,0 +1,36 @@
import { BuildingListingsDrawer } from 'tradein-mvp-frontend';
// Правый drawer с активными объявлениями выбранного дома (/trade-in/sale-share):
// createPortal в document.body, .ss-drawer-overlay = position:fixed по всему
// вьюпорту (как MapPicker) → cardMode:single. Шапка — адрес + тепловой бейдж
// доли + «N из M квартир» (из props). Тело FETCH-COUPLED: useBuildingListings
// (GET /buildings/{id}/listings) — data-prop нет, глобальный query-cache пуст →
// в офлайн-capture честный pending/error-state («Загрузка объявлений…» /
// «Не удалось загрузить объявления дома»). На реальной странице — список
// объявлений с ценой, ₽/м², этажом и ссылкой на оригинал.
const building = {
house_id: 3101,
address: 'ул. Викулова, 46',
lat: 56.8412,
lon: 60.5556,
sale_share_pct: 34.6,
sale_share_pct_45d: 41.2,
listings_45d: 33,
over_100: false,
active_secondary: 27,
flat_count_effective: 78,
gar_match_method: 'cadastre',
median_price_rub: 4_950_000,
median_price_per_m2: 158_500,
avg_days_on_market: 74,
year_built: 1972,
house_type: 'panel',
total_floors: 9,
series_name: '1-468',
is_emergency: false,
};
/** Открытый drawer дома «ул. Викулова, 46» (34.6% в продаже, 27 из 78 квартир). */
export const Default = () => (
<BuildingListingsDrawer building={building} onClose={() => {}} />
);

View file

@ -0,0 +1,6 @@
import { CianValuationCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Компактный блок «Оценка Cian» продажа 9.70 млн + аренда 42 000 /мес,
* спарклайн за 6 мес и бейдж 3.2%. Узкий компонент (section, не full-width card). */
export const Default = () => <CianValuationCard data={FIXTURE_ESTIMATE.cian_valuation} />;

View file

@ -0,0 +1,6 @@
import { DataQualitySection } from 'tradein-mvp-frontend';
/** Coverage-секция: таблица заполнения полей по источникам + обогащение домов.
* Фетчит /admin/scraper/data-quality через useQuery (без data-prop). В превью
* без сети рендерит заголовок + hint + graceful-degradation. Fetch-coupled. */
export const Default = () => <DataQualitySection />;

View file

@ -0,0 +1,6 @@
import { DealsCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Секция 3 «Сделки» фактические ДКП-сделки Росреестра по аналогам (2 строки):
* count-strip (кол-во / медиана / диапазон) + чипы источников + таблица. */
export const Default = () => <DealsCard estimate={FIXTURE_ESTIMATE} />;

View file

@ -0,0 +1,25 @@
import { DistributionCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
// Канонический FIXTURE_ESTIMATE содержит лишь 3 аналога — DistributionCard рисует
// гистограмму только при ≥8 (иначе fallback-текст «мало аналогов»). Расширяем до 10
// правдоподобных ЕКБ-аналогов (₽/м² вокруг median 178 000) — чтобы показать сам график.
// Реалистичные ЕКБ-улицы/цены, не foo/test; правим только этот preview-файл.
const ekbAnalogs = [
{ address: 'ул. Репина, 73', area_m2: 54, rooms: 2, floor: 7, total_floors: 16, price_rub: 9_700_000, price_per_m2: 179_629, listing_date: '2026-05-12', days_on_market: 18, photo_url: null, source: 'avito', source_url: null, distance_m: 120, tier: null, lat: 56.8401, lon: 60.5702 },
{ address: 'ул. Викулова, 33', area_m2: 57, rooms: 2, floor: 4, total_floors: 10, price_rub: 9_950_000, price_per_m2: 174_561, listing_date: '2026-05-20', days_on_market: 10, photo_url: null, source: 'cian', source_url: null, distance_m: 340, tier: null, lat: 56.8389, lon: 60.5681 },
{ address: 'ул. Кирова, 28', area_m2: 53, rooms: 2, floor: 9, total_floors: 12, price_rub: 9_400_000, price_per_m2: 177_358, listing_date: '2026-05-04', days_on_market: 31, photo_url: null, source: 'avito', source_url: null, distance_m: 510, tier: null, lat: 56.8372, lon: 60.5749 },
{ address: 'ул. Серафимы Дерябиной, 24', area_m2: 56, rooms: 2, floor: 6, total_floors: 14, price_rub: 9_600_000, price_per_m2: 171_429, listing_date: '2026-05-08', days_on_market: 42, photo_url: null, source: 'cian', source_url: null, distance_m: 680, tier: null, lat: 56.8318, lon: 60.5666 },
{ address: 'ул. Ясная, 6', area_m2: 52, rooms: 2, floor: 11, total_floors: 16, price_rub: 9_600_000, price_per_m2: 184_615, listing_date: '2026-05-18', days_on_market: 14, photo_url: null, source: 'avito', source_url: null, distance_m: 430, tier: null, lat: 56.8295, lon: 60.5803 },
{ address: 'ул. Белореченская, 17', area_m2: 58, rooms: 2, floor: 3, total_floors: 9, price_rub: 9_800_000, price_per_m2: 168_966, listing_date: '2026-04-28', days_on_market: 55, photo_url: null, source: 'yandex', source_url: null, distance_m: 900, tier: null, lat: 56.8267, lon: 60.5611 },
{ address: 'ул. Гурзуфская, 16', area_m2: 55, rooms: 2, floor: 8, total_floors: 12, price_rub: 10_000_000, price_per_m2: 181_818, listing_date: '2026-05-22', days_on_market: 9, photo_url: null, source: 'cian', source_url: null, distance_m: 260, tier: null, lat: 56.8344, lon: 60.5727 },
{ address: 'ул. Посадская, 40', area_m2: 51, rooms: 2, floor: 5, total_floors: 10, price_rub: 9_600_000, price_per_m2: 188_235, listing_date: '2026-05-15', days_on_market: 22, photo_url: null, source: 'avito', source_url: null, distance_m: 770, tier: null, lat: 56.8231, lon: 60.5832 },
{ address: 'ул. Шаумяна, 86', area_m2: 60, rooms: 2, floor: 2, total_floors: 18, price_rub: 9_900_000, price_per_m2: 165_000, listing_date: '2026-04-25', days_on_market: 60, photo_url: null, source: 'yandex', source_url: null, distance_m: 1100, tier: null, lat: 56.8189, lon: 60.5598 },
{ address: 'ул. Чкалова, 124', area_m2: 54, rooms: 2, floor: 12, total_floors: 19, price_rub: 10_400_000, price_per_m2: 192_593, listing_date: '2026-05-24', days_on_market: 7, photo_url: null, source: 'cian', source_url: null, distance_m: 350, tier: null, lat: 56.8276, lon: 60.5689 },
];
const estimate = { ...FIXTURE_ESTIMATE, analogs: ekbAnalogs, n_analogs: ekbAnalogs.length };
/** Гистограмма распределения /м² по 10 аналогам с маркером «ваша квартира»
* на median 178 000 /м² (оранжевый столбец/референс-линия). */
export const Default = () => <DistributionCard estimate={estimate} />;

View file

@ -0,0 +1,13 @@
import { EstimateForm } from 'tradein-mvp-frontend';
/** Шаг 1/1 параметры квартиры (адрес + площадь/комнаты/этаж/тип/ремонт +
* свёрнутый CRM-блок). Полное «спокойное» состояние: не считает, без ошибок. */
export const Default = () => (
<EstimateForm
onSubmit={() => {}}
isPending={false}
error={null}
remaining={12}
limit={15}
/>
);

View file

@ -0,0 +1,25 @@
import { ExposureCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
// Канонический FIXTURE_ESTIMATE содержит лишь 3 аналога — ExposureCard рисует
// scatter (цена × срок экспозиции) только при ≥8 точках с days_on_market (иначе
// fallback-текст «недостаточно данных»). Расширяем до 10 правдоподобных ЕКБ-аналогов
// (у всех непустой days_on_market) — чтобы показать сам график. Правим только этот файл.
const ekbAnalogs = [
{ address: 'ул. Репина, 73', area_m2: 54, rooms: 2, floor: 7, total_floors: 16, price_rub: 9_700_000, price_per_m2: 179_629, listing_date: '2026-05-12', days_on_market: 18, photo_url: null, source: 'avito', source_url: null, distance_m: 120, tier: null, lat: 56.8401, lon: 60.5702 },
{ address: 'ул. Викулова, 33', area_m2: 57, rooms: 2, floor: 4, total_floors: 10, price_rub: 9_950_000, price_per_m2: 174_561, listing_date: '2026-05-20', days_on_market: 10, photo_url: null, source: 'cian', source_url: null, distance_m: 340, tier: null, lat: 56.8389, lon: 60.5681 },
{ address: 'ул. Кирова, 28', area_m2: 53, rooms: 2, floor: 9, total_floors: 12, price_rub: 9_400_000, price_per_m2: 177_358, listing_date: '2026-05-04', days_on_market: 31, photo_url: null, source: 'avito', source_url: null, distance_m: 510, tier: null, lat: 56.8372, lon: 60.5749 },
{ address: 'ул. Серафимы Дерябиной, 24', area_m2: 56, rooms: 2, floor: 6, total_floors: 14, price_rub: 9_600_000, price_per_m2: 171_429, listing_date: '2026-05-08', days_on_market: 42, photo_url: null, source: 'cian', source_url: null, distance_m: 680, tier: null, lat: 56.8318, lon: 60.5666 },
{ address: 'ул. Ясная, 6', area_m2: 52, rooms: 2, floor: 11, total_floors: 16, price_rub: 9_600_000, price_per_m2: 184_615, listing_date: '2026-05-18', days_on_market: 14, photo_url: null, source: 'avito', source_url: null, distance_m: 430, tier: null, lat: 56.8295, lon: 60.5803 },
{ address: 'ул. Белореченская, 17', area_m2: 58, rooms: 2, floor: 3, total_floors: 9, price_rub: 9_800_000, price_per_m2: 168_966, listing_date: '2026-04-28', days_on_market: 55, photo_url: null, source: 'yandex', source_url: null, distance_m: 900, tier: null, lat: 56.8267, lon: 60.5611 },
{ address: 'ул. Гурзуфская, 16', area_m2: 55, rooms: 2, floor: 8, total_floors: 12, price_rub: 10_000_000, price_per_m2: 181_818, listing_date: '2026-05-22', days_on_market: 9, photo_url: null, source: 'cian', source_url: null, distance_m: 260, tier: null, lat: 56.8344, lon: 60.5727 },
{ address: 'ул. Посадская, 40', area_m2: 51, rooms: 2, floor: 5, total_floors: 10, price_rub: 9_600_000, price_per_m2: 188_235, listing_date: '2026-05-15', days_on_market: 22, photo_url: null, source: 'avito', source_url: null, distance_m: 770, tier: null, lat: 56.8231, lon: 60.5832 },
{ address: 'ул. Шаумяна, 86', area_m2: 60, rooms: 2, floor: 2, total_floors: 18, price_rub: 9_900_000, price_per_m2: 165_000, listing_date: '2026-04-25', days_on_market: 60, photo_url: null, source: 'yandex', source_url: null, distance_m: 1100, tier: null, lat: 56.8189, lon: 60.5598 },
{ address: 'ул. Чкалова, 124', area_m2: 54, rooms: 2, floor: 12, total_floors: 19, price_rub: 10_400_000, price_per_m2: 192_593, listing_date: '2026-05-24', days_on_market: 7, photo_url: null, source: 'cian', source_url: null, distance_m: 350, tier: null, lat: 56.8276, lon: 60.5689 },
];
const estimate = { ...FIXTURE_ESTIMATE, analogs: ekbAnalogs, n_analogs: ekbAnalogs.length };
/** Scatter «цена × срок экспозиции» по 10 аналогам (дешевле быстрее),
* вертикальная референс-линия на median 9.85 млн . */
export const Default = () => <ExposureCard estimate={estimate} />;

View file

@ -0,0 +1,13 @@
import { HeroSummary } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from './_fixtures';
/** Секция 1 «Сводка» медиана + достоверность CV + параметры объекта.
* Фото-аналог = плейсхолдер (фикстура офлайн, photo_url: null). */
export const Default = () => (
<HeroSummary
estimate={FIXTURE_ESTIMATE}
input={FIXTURE_INPUT}
onResubmit={() => {}}
isResubmitting={false}
/>
);

View file

@ -0,0 +1,9 @@
import { HeroTransparency } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Блок «доверие + действия» в шапке результата CTA «заявка» (лид-инбокс задан
* в shim env), «Скачать PDF», свежесть данных и collapsible «Как рассчитано»
* (band достоверности, аналоги, точность адреса). Generic-бренд (brandSlug=null). */
export const Default = () => (
<HeroTransparency estimate={FIXTURE_ESTIMATE} brandSlug={null} brandName={null} />
);

View file

@ -0,0 +1,6 @@
import { HouseAnalyticsKpiRow } from 'tradein-mvp-frontend';
import { FIXTURE_ANALYTICS } from './_fixtures';
/** KPI-строка аналитики дома 3 карточки: средняя экспозиция, средний торг,
* доля снятых (sold_count из total_lots). */
export const Default = () => <HouseAnalyticsKpiRow kpi={FIXTURE_ANALYTICS.kpi} />;

View file

@ -0,0 +1,9 @@
import { HouseAnalyticsSection } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Секция-обёртка «Аналитика дома» fetch-coupled: тянет house-analytics +
* sell-time-sensitivity по estimateId через TanStack Query. Глобальный provider
* preview-режима с пустым кэшем isPending секция возвращает null (пустой
* рендер). Содержимое покрыто отдельными ячейками HouseAnalyticsKpiRow /
* PriceHistoryChart / RecentSoldList / SellTimeSensitivity. */
export const Default = () => <HouseAnalyticsSection estimateId={FIXTURE_ESTIMATE.estimate_id} />;

View file

@ -0,0 +1,6 @@
import { HouseInfoCard } from 'tradein-mvp-frontend';
import { FIXTURE_HOUSES } from './_fixtures';
/** Карточка «Дом» ближайший дом из выборки: адрес, рейтинг, параметры дома
* (год, этажность, тип, лифты, двор, застройщик). length>0 не null. */
export const Default = () => <HouseInfoCard houses={FIXTURE_HOUSES} isLoading={false} />;

View file

@ -0,0 +1,6 @@
import { IMVBenchmark } from 'tradein-mvp-frontend';
import { FIXTURE_IMV } from './_fixtures';
/** Avito IMV benchmark рекомендованная цена + диапазон + аналоги в рынке,
* сравнение с нашей медианой (diff_pct). available:true не null. */
export const Default = () => <IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} />;

View file

@ -0,0 +1,9 @@
import { ListingsCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Секция 2 «Рынок» объявления-аналоги: count-strip, фильтры/источники,
* ценовой бар P25P75 + таблица. cian-price-changes fetch офлайн пуст бейджи
* скидок не показываются (graceful), всё остальное берётся из estimate-пропа. */
export const Default = () => (
<ListingsCard estimate={FIXTURE_ESTIMATE} estimateId={FIXTURE_ESTIMATE.estimate_id} />
);

View file

@ -0,0 +1,25 @@
import { LocationDrawer } from 'tradein-mvp-frontend';
// «ПОЯСНЕНИЕ К РАСЧЁТУ» — правый drawer HUD «МЕРА Оценка» (/trade-in/v2),
// открывается с «?» у «КОЭФ. ЛОКАЦИИ» в HeroBar. Честная методика: как
// агрегируются источники (Циан/Я.Недвижимость/Авито/Домклик + Росреестр) и
// явная плашка «коэффициент локации в разработке». Контент статичный —
// данных не принимает, только open/onClose. Drawer absolute-позиционирован
// (width 452 + scrim) — рендерим contained внутри relative-обёртки.
/** Открытое состояние: scrim + выдвинутая панель с методикой и info-плашкой
* о локации. */
export const Default = () => (
<div
style={{
position: 'relative',
height: 640,
borderRadius: 10,
overflow: 'hidden',
background:
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
}}
>
<LocationDrawer open={true} onClose={() => {}} />
</div>
);

View file

@ -0,0 +1,8 @@
import { MapCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Карта аналитики target + 3 аналога с гео-точками (Leaflet + OSM-тайлы).
* NB: компонент тянет Leaflet с unpkg CDN + тайлы с tile.openstreetmap.org. В
* офлайн-capture карта не грузится (loadLeaflet error mapError, либо серый
* холст --surface-2). Card-обёртка (шапка/легенда/футер) рендерится в любом случае. */
export const Default = () => <MapCard estimate={FIXTURE_ESTIMATE} />;

View file

@ -0,0 +1,8 @@
import { MapPicker } from 'tradein-mvp-frontend';
/** Модалка выбора адреса на карте ЕКБ (Leaflet+OSM). Рендерится через
* createPortal в document.body как position:fixed оверлей НЕ заперт в ячейке
* грида. Leaflet тянется с CDN (офлайн «не удалось загрузить карту»). */
export const Default = () => (
<MapPicker onPick={() => {}} onClose={() => {}} />
);

View file

@ -0,0 +1,13 @@
import { NoAccessScreen } from 'tradein-mvp-frontend';
/** Полноэкранный «доступа нет» pure-props, 4 реальных варианта enum.
* variant сменяет title + subtitle (trial со ссылкой в Telegram). */
export const UserDenied = () => <NoAccessScreen variant="user" />;
export const PathDenied = () => (
<NoAccessScreen variant="path" path="/trade-in/scrapers/avito" />
);
export const SessionExpired = () => <NoAccessScreen variant="session" />;
export const TrialEnded = () => <NoAccessScreen variant="trial" />;

View file

@ -0,0 +1,6 @@
import { OfferCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Секция 4 «Оффер» разбивка издержек самостоятельной продажи на дефолтных
* ставках (brandSlug меняет только PDF-endpoint, не вид). */
export const Default = () => <OfferCard estimate={FIXTURE_ESTIMATE} brandSlug={null} />;

View file

@ -0,0 +1,6 @@
import { PacingSection } from 'tradein-mvp-frontend';
/** Pacing-интервалы запросов по провайдерам. Фетчит /admin/scraper/pacing через
* useQuery (data-prop нет). В превью без сети заголовок + hint +
* graceful-degradation. Fetch-coupled. */
export const Default = () => <PacingSection />;

View file

@ -0,0 +1,8 @@
import { PhotoUpload } from 'tradein-mvp-frontend';
/** Загрузка фото квартиры (#394). Карточка с заголовком, счётчиком N/12 и
* файл-инпутом. Реальный UUID активный аплоадер «0 / 12» (фетч списка фото
* офлайн молча игнорируется). */
export const Default = () => (
<PhotoUpload estimateId="3f2a9c10-7b4d-4e8a-9f12-6c5b1a2d3e4f" />
);

View file

@ -0,0 +1,8 @@
import { PlacementHistoryCard } from 'tradein-mvp-frontend';
/** История продаж в доме. Fetch-coupled: данные тянет useEstimatePlacementHistory
* по estimateId; при пустом query-кэше isPending компонент возвращает null
* (нет loading/empty-вёрстки). Передать данные пропом нельзя. */
export const Default = () => (
<PlacementHistoryCard estimateId="preview-0000-0000-0000-000000000000" />
);

View file

@ -0,0 +1,8 @@
import { PriceHistoryChart } from 'tradein-mvp-frontend';
import { FIXTURE_ANALYTICS } from './_fixtures';
/** «История цен в этом доме» recharts line-chart медианы /м² по годам
* (Avito-серия; фикстура без Яндекс-точек одна линия). */
export const Default = () => (
<PriceHistoryChart points={FIXTURE_ANALYTICS.price_history} />
);

View file

@ -0,0 +1,24 @@
import { PriceRangeBar } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
const wrap = (node: React.ReactNode) => (
<div style={{ maxWidth: 460, padding: 12 }}>{node}</div>
);
/** Типовой коридор asking-цены с медианой по центру. */
export const Default = () =>
wrap(
<PriceRangeBar
rangeLow={FIXTURE_ESTIMATE.range_low_rub}
rangeHigh={FIXTURE_ESTIMATE.range_high_rub}
median={FIXTURE_ESTIMATE.median_price_rub}
/>,
);
/** Широкий разброс — медиана смещена влево. */
export const WideSpread = () =>
wrap(<PriceRangeBar rangeLow={7_400_000} rangeHigh={12_900_000} median={9_100_000} />);
/** Узкий коридор — высокая достоверность. */
export const Tight = () =>
wrap(<PriceRangeBar rangeLow={9_600_000} rangeHigh={10_100_000} median={9_850_000} />);

View file

@ -0,0 +1,5 @@
import { PriceTrendCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** «Динамика ₽/м²» — линия recharts по 6 месяцам (168k → 178k), дельта в шапке. */
export const Default = () => <PriceTrendCard estimate={FIXTURE_ESTIMATE} />;

View file

@ -0,0 +1,6 @@
import { ProviderProxySection } from 'tradein-mvp-frontend';
/** Прокси-блок внутри вкладки провайдера. Фетчит /admin/scraper/health через
* useScraperHealth (по source), data-prop нет. В превью без сети заголовок
* «Прокси» + loading/error. Fetch-coupled. */
export const Default = () => <ProviderProxySection source="avito" />;

View file

@ -0,0 +1,8 @@
import { RecentSoldList } from 'tradein-mvp-frontend';
import { FIXTURE_ANALYTICS } from './_fixtures';
/** «Недавно снятые (12 мес)» таблица лотов: комнаты/площадь/этаж, цена, торг,
* дата снятия, экспозиция. */
export const Default = () => (
<RecentSoldList items={FIXTURE_ANALYTICS.recent_sold} />
);

View file

@ -0,0 +1,10 @@
import { ResultPanel } from 'tradein-mvp-frontend';
/**
* ResultPanel (v2) центральная панель результата оценки на `/trade-in/v2`:
* одна цифра-герой (ожидаемая цена продажи / «оценка»), тиры median + ДКП,
* доверительные диапазоны, источники и мини-гистограмма распределения.
* Данные по умолчанию берутся из встроенной `RESULT_FIXTURE` компонента,
* поэтому карточке достаточно передать `onNavigate` (навигация по секциям).
*/
export const Default = () => <ResultPanel onNavigate={() => {}} />;

View file

@ -0,0 +1,15 @@
import { RouteGuard } from 'tradein-mvp-frontend';
/** RBAC-обёртка. useMe() читает /api/v1/me в превью-окружении сети нет,
* поэтому guard рендерит свой gated-state (NoAccessScreen "Доступа нет" при
* ошибке /me, либо ничего во время загрузки). Fetch-coupled на useMe. */
export const Default = () => (
<RouteGuard>
<div className="card" style={{ padding: 24 }}>
<h2 style={{ margin: 0 }}>Защищённый раздел</h2>
<p style={{ margin: '8px 0 0', color: 'var(--fg-secondary)' }}>
Этот контент виден только при разрешённой роли.
</p>
</div>
</RouteGuard>
);

View file

@ -0,0 +1,6 @@
import { RunsTable } from 'tradein-mvp-frontend';
/** История прогонов скраппера. Фетчит /admin/scrape/runs через useQuery
* (data-prop нет), но заголовок + hint + фильтры (источник / статус-чипы)
* рендерятся всегда это видимый styled-каркас. Таблица fetch-coupled. */
export const Default = () => <RunsTable source="avito" />;

View file

@ -0,0 +1,54 @@
import { SaleShareControls } from 'tradein-mvp-frontend';
// Панель «Порог и фильтры» страницы /trade-in/sale-share. Controlled-компонент:
// состояние живёт в page.tsx и приходит через props. Сводка (гистограмма
// распределения sale_share_pct) — реалистичные значения по покрытию ЕКБ
// (view v_building_sale_share ≈ 6.7k домов вторички, знаменатель ГАР у ~92%).
const summary = {
total_secondary_buildings: 6667,
buildings_with_denominator: 6143,
coverage_pct: 92.1,
max_pct: 42.0,
p95_pct: 11.3,
histogram: [
{ bucket: '0-5', count: 4980 },
{ bucket: '5-10', count: 642 },
{ bucket: '10-20', count: 298 },
{ bucket: '20-30', count: 121 },
{ bucket: '30-50', count: 57 },
{ bucket: '50-100', count: 18 },
{ bucket: '100+', count: 6 },
],
};
const HOUSE_TYPES = ['panel', 'brick', 'monolith', 'monolith_brick', 'block', 'stalin'];
/** Порог 8% на слайдере: корзины ниже порога приглушены (opacity), тепловая
* окраска столбцов greenred повторяет цвет маркеров карты. Фильтры: город,
* цена, год постройки, тип дома, мин. объявлений, сортировка. */
export const Default = () => (
<SaleShareControls
summary={summary}
minPct={8}
onMinPct={() => {}}
city="Екатеринбург"
onCity={() => {}}
priceMin=""
onPriceMin={() => {}}
priceMax=""
onPriceMax={() => {}}
yearMin=""
onYearMin={() => {}}
yearMax=""
onYearMax={() => {}}
houseType=""
onHouseType={() => {}}
houseTypeOptions={HOUSE_TYPES}
sort="share_desc"
onSort={() => {}}
window="now"
onWindow={() => {}}
minCount={3}
onMinCount={() => {}}
/>
);

View file

@ -0,0 +1,50 @@
import { SaleShareList } from 'tradein-mvp-frontend';
// Список домов /trade-in/sale-share, отсортирован по доле в продаже (server-side
// share_desc). Реалистичные дома ЕКБ (view v_building_sale_share): адрес,
// тепловой бейдж доли, «N из M квартир», медиана + ₽/м², экспозиция,
// год/тип/этажность/серия. Включены оба edge-бейджа: «аварийный» и
// «возможно, несколько корпусов» (over_100 — ГАР-коллизия адреса).
const buildings = [
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
];
/** Полная таблица (6 домов, выбран «ул. Викулова, 46» строка is-selected),
* сортировка по доле, кнопка «Объявления » ведёт в drawer. */
export const Default = () => (
<SaleShareList
buildings={buildings}
sort="share_desc"
onSort={() => {}}
selectedHouseId={3101}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
isLoading={false}
isError={false}
minPct={5}
window="now"
/>
);
/** Пустой результат — фильтры отсекли все дома (честный empty-state). */
export const Empty = () => (
<SaleShareList
buildings={[]}
sort="share_desc"
onSort={() => {}}
selectedHouseId={null}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
isLoading={false}
isError={false}
minPct={25}
window="now"
/>
);

View file

@ -0,0 +1,30 @@
import { SaleShareMap } from 'tradein-mvp-frontend';
// Тепловая карта домов /trade-in/sale-share (Leaflet + OSM с CDN, как
// MapCard/MapPicker). Цвет и радиус circleMarker растут с sale_share_pct
// (green → red, over_100 → тёмно-красный). 8 домов ЕКБ с координатами;
// выбранный дом (selectedHouseId) получает открытый popup. OSM-тайлы —
// внешний fetch: в офлайн-capture подложка может не прогрузиться,
// маркеры/popup/контролы рендерятся всегда.
const buildings = [
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
{ house_id: 5871, address: 'ул. Крауля, 44', lat: 56.8443, lon: 60.5610, sale_share_pct: 17.9, sale_share_pct_45d: 19.6, listings_45d: 14, over_100: false, active_secondary: 12, flat_count_effective: 67, gar_match_method: 'cadastre', median_price_rub: 5_150_000, median_price_per_m2: 152_900, avg_days_on_market: 58, year_built: 1980, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 6402, address: 'ул. 8 Марта, 190', lat: 56.8043, lon: 60.6094, sale_share_pct: 3.4, sale_share_pct_45d: 4.0, listings_45d: 11, over_100: false, active_secondary: 9, flat_count_effective: 262, gar_match_method: 'cadastre', median_price_rub: 9_300_000, median_price_per_m2: 176_400, avg_days_on_market: 33, year_built: 2019, house_type: 'monolith', total_floors: 26, series_name: null, is_emergency: false },
];
/** Карта ЕКБ: 8 тепловых маркеров, выбран «ул. Викулова, 46» открыт popup
* (адрес, % в продаже, N из M квартир). */
export const Default = () => (
<SaleShareMap
buildings={buildings}
selectedHouseId={3101}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
/>
);

View file

@ -0,0 +1,25 @@
import { ScheduleControl } from 'tradein-mvp-frontend';
/** Расписание + ручной запуск city-sweep. Фетчит /admin/scrape/schedules через
* useSchedules (статус-блок fetch-coupled), но форма настроек (окно МСК,
* pages/anchor, detail top-N, delay, радиус, enrich) рендерится всегда
* полный styled-каркас формы. paramConfig avito-дефолты (с detail-параметрами). */
const AVITO_PARAMS = {
hasDetailParams: true,
hasEnrichAddress: false,
defaults: {
pages_per_anchor: 3,
radius_m: 1500,
request_delay_sec: 4,
detail_top_n: 12,
enrich_houses: true,
},
};
export const Default = () => (
<ScheduleControl
source="avito"
scheduleSource="avito_city_sweep"
paramConfig={AVITO_PARAMS}
/>
);

View file

@ -0,0 +1,39 @@
import { SectionOverlay } from 'tradein-mvp-frontend';
// Glass/blur оверлей секций HUD «МЕРА Оценка» (/trade-in/v2): скобки-уголки,
// шапка «номер секции + заголовок + ← К ОЦЕНКЕ», скроллируемое тело со
// сменной view. Позиционируется absolute относительно артборда — в preview
// рендерим contained внутри relative-обёртки с фоном v2-страницы.
// Данные не передаём: каждая view падает на свой встроенный fixture-набор
// (HISTORY_FIXTURE / ANALYTICS_FIXTURE …) — как storybook/unwired usage.
const Artboard = ({ children }: { children?: unknown }) => (
<div
style={{
position: 'relative',
height: 680,
borderRadius: 10,
overflow: 'hidden',
background:
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
}}
>
{children as React.ReactNode}
</div>
);
/** Секция 04 «ПРОДАЖИ В ДОМЕ» (active=1 HistoryView на fixture-данных):
* таблица ДКП-продаж дома с ценами и датами. */
export const HistorySection = () => (
<Artboard>
<SectionOverlay active={1} onClose={() => {}} onNavigate={() => {}} />
</Artboard>
);
/** Секция 06 «АНАЛИТИКА ДОМА» (active=3 AnalyticsView): KPI дома,
* динамика цены, недавние продажи. */
export const AnalyticsSection = () => (
<Artboard>
<SectionOverlay active={3} onClose={() => {}} onNavigate={() => {}} />
</Artboard>
);

View file

@ -0,0 +1,6 @@
import { SellTimeSensitivity } from 'tradein-mvp-frontend';
import { FIXTURE_SELLTIME } from './_fixtures';
/** «Срок продажи в зависимости от цены» — 4 бакета премии (5% / медиана / +5% /
* +10%) с медианой экспозиции и p25p75. Принимает data-пропом напрямую. */
export const Default = () => <SellTimeSensitivity data={FIXTURE_SELLTIME} />;

View file

@ -0,0 +1,6 @@
import { SourcesProgress } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** Шаг B «Агрегация» 7 строк-источников. Циан/Авито/Росреестр = done (с лотами),
* ДомКлик/Restate/Я.Недв/N1 = idle. isPending=false финальное состояние, бар 100%. */
export const Default = () => <SourcesProgress estimate={FIXTURE_ESTIMATE} isPending={false} />;

View file

@ -0,0 +1,8 @@
import { StreetDealsCard } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE } from './_fixtures';
/** «По вашей улице» ДКП-сделки Росреестра + историч. ASK.
* FETCH-COUPLED: данные ТОЛЬКО из useSalesVsListings (нет data-prop). Глобальный
* query-cache в preview пуст isLoading/isError `return null`. В офлайн-capture
* карточка рендерится пусто; на реальной странице таблица сделок с привязкой к ASK. */
export const Default = () => <StreetDealsCard estimate={FIXTURE_ESTIMATE} />;

View file

@ -0,0 +1,6 @@
import { SystemHealthSection } from 'tradein-mvp-frontend';
/** Статус системы скраппера: fetch-режим, browser-сервис, прокси провайдеров.
* Фетчит /admin/scraper/health через useScraperHealth (data-prop нет). В превью
* без сети заголовок + hint + loading/error. Fetch-coupled. */
export const Default = () => <SystemHealthSection />;

View file

@ -0,0 +1,5 @@
import { TestPresets } from 'tradein-mvp-frontend';
/** Быстрое заполнение формы тестовыми квартирами ЕКБ сетка из 6 preset-чипов
* (адрес + подсказка по покрытию). Self-contained (`<style>` внутри). */
export const Default = () => <TestPresets onPick={() => {}} />;

View file

@ -0,0 +1,6 @@
import { Topbar } from 'tradein-mvp-frontend';
/** Шапка приложения «Мера» бренд-марка + навигация. useMe/useBrand офлайн
* не резолвятся fallback показывает полный набор nav-айтемов; UserMenu сам
* рендерит null. Активная вкладка «Оценка». */
export const Default = () => <Topbar active="estimate" />;

View file

@ -0,0 +1,10 @@
import { UserMenu } from 'tradein-mvp-frontend';
/** Аватар-кнопка личного кабинета в Topbar. Читает useMe() без данных /me
* (isLoading || error || !data) компонент намеренно возвращает null.
* Fetch-coupled: в превью без сети рендерит пусто. */
export const Default = () => (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 12 }}>
<UserMenu />
</div>
);

View file

@ -0,0 +1,9 @@
import { WhatIfPanel } from 'tradein-mvp-frontend';
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from './_fixtures';
/** Интерактив «Что-если» segmented ремонт + этаж (stepper/slider) + площадь +
* балкон, авто-пересчёт. Без взаимодействия показывает baseline-оценку
* (mutation idle). */
export const Default = () => (
<WhatIfPanel baseEstimate={FIXTURE_ESTIMATE} baseInput={FIXTURE_INPUT} />
);

View file

@ -0,0 +1,3 @@
// Shared preview fixtures — re-export the repo's own offline UI-preview fixture
// (realistic ЕКБ 2-к secondary). Type imports inside are erased by esbuild.
export * from '../../tradein-mvp/frontend/src/app/ui-preview/estimate/fixture';

8
.gitignore vendored
View file

@ -1,4 +1,4 @@
# Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl) # Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl)
*.jsonl *.jsonl
!memory/memory-gendesign.jsonl !memory/memory-gendesign.jsonl
@ -44,7 +44,6 @@ tradein-mvp/scripts/.*cookies*.json
# Agents and rules ARE tracked: they are team-shared config. # Agents and rules ARE tracked: they are team-shared config.
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern # Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
# does not work because git cannot re-include files under an ignored parent. # does not work because git cannot re-include files under an ignored parent.
.claude/settings.json
.claude/settings.local.json .claude/settings.local.json
.claude/cache/ .claude/cache/
.claude/sessions/ .claude/sessions/
@ -90,3 +89,8 @@ data/osrm/*
# Log cruft at repo root # Log cruft at repo root
debug.log debug.log
.ds-sync/
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules