chore(design-sync): sync inputs for claude.ai/design trade-in DS #1984
46 changed files with 843 additions and 0 deletions
79
.design-sync/AUTHORING.md
Normal file
79
.design-sync/AUTHORING.md
Normal 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.1–10.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 1–3 more ONLY if they visibly
|
||||
differ (sweep a real variant axis: a state, an enum, empty-vs-full). Budget 1–4 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).
|
||||
34
.design-sync/NOTES.md
Normal file
34
.design-sync/NOTES.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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.
|
||||
36
.design-sync/config.json
Normal file
36
.design-sync/config.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"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
|
||||
},
|
||||
"provider": {
|
||||
"component": "PreviewProvider"
|
||||
},
|
||||
"extraEntries": [
|
||||
"../../.design-sync/preview-provider.tsx"
|
||||
],
|
||||
"readmeHeader": ".design-sync/conventions.md"
|
||||
}
|
||||
50
.design-sync/conventions.md
Normal file
50
.design-sync/conventions.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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` |
|
||||
|
||||
```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.
|
||||
178
.design-sync/overrides/source-kit.mjs
Normal file
178
.design-sync/overrides/source-kit.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// 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 } 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);
|
||||
}
|
||||
const comps = srcFiles.filter((p) => SRC_IMPL_RX.test(p) && !NON_IMPL_RX.test(p));
|
||||
// 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');
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import ${JSON.stringify(slash(shim))};\n` +
|
||||
comps.map((p) => `export * from ${JSON.stringify(p)};`).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 (!components.length && synthEntry) {
|
||||
components = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
61
.design-sync/preview-provider.tsx
Normal file
61
.design-sync/preview-provider.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"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}>{children}</QueryClientProvider>;
|
||||
}
|
||||
12
.design-sync/previews/AddressInput.tsx
Normal file
12
.design-sync/previews/AddressInput.tsx
Normal 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…"
|
||||
/>
|
||||
);
|
||||
6
.design-sync/previews/CianValuationCard.tsx
Normal file
6
.design-sync/previews/CianValuationCard.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/DataQualitySection.tsx
Normal file
6
.design-sync/previews/DataQualitySection.tsx
Normal 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 />;
|
||||
6
.design-sync/previews/DealsCard.tsx
Normal file
6
.design-sync/previews/DealsCard.tsx
Normal 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} />;
|
||||
25
.design-sync/previews/DistributionCard.tsx
Normal file
25
.design-sync/previews/DistributionCard.tsx
Normal 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} />;
|
||||
13
.design-sync/previews/EstimateForm.tsx
Normal file
13
.design-sync/previews/EstimateForm.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
25
.design-sync/previews/ExposureCard.tsx
Normal file
25
.design-sync/previews/ExposureCard.tsx
Normal 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} />;
|
||||
13
.design-sync/previews/HeroSummary.tsx
Normal file
13
.design-sync/previews/HeroSummary.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
9
.design-sync/previews/HeroTransparency.tsx
Normal file
9
.design-sync/previews/HeroTransparency.tsx
Normal 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} />
|
||||
);
|
||||
6
.design-sync/previews/HouseAnalyticsKpiRow.tsx
Normal file
6
.design-sync/previews/HouseAnalyticsKpiRow.tsx
Normal 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} />;
|
||||
9
.design-sync/previews/HouseAnalyticsSection.tsx
Normal file
9
.design-sync/previews/HouseAnalyticsSection.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/HouseInfoCard.tsx
Normal file
6
.design-sync/previews/HouseInfoCard.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/IMVBenchmark.tsx
Normal file
6
.design-sync/previews/IMVBenchmark.tsx
Normal 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} />;
|
||||
9
.design-sync/previews/ListingsCard.tsx
Normal file
9
.design-sync/previews/ListingsCard.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { ListingsCard } from 'tradein-mvp-frontend';
|
||||
import { FIXTURE_ESTIMATE } from './_fixtures';
|
||||
|
||||
/** Секция 2 «Рынок» — объявления-аналоги: count-strip, фильтры/источники,
|
||||
* ценовой бар P25–P75 + таблица. cian-price-changes fetch офлайн пуст → бейджи
|
||||
* скидок не показываются (graceful), всё остальное берётся из estimate-пропа. */
|
||||
export const Default = () => (
|
||||
<ListingsCard estimate={FIXTURE_ESTIMATE} estimateId={FIXTURE_ESTIMATE.estimate_id} />
|
||||
);
|
||||
8
.design-sync/previews/MapCard.tsx
Normal file
8
.design-sync/previews/MapCard.tsx
Normal 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} />;
|
||||
8
.design-sync/previews/MapPicker.tsx
Normal file
8
.design-sync/previews/MapPicker.tsx
Normal 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={() => {}} />
|
||||
);
|
||||
13
.design-sync/previews/NoAccessScreen.tsx
Normal file
13
.design-sync/previews/NoAccessScreen.tsx
Normal 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" />;
|
||||
6
.design-sync/previews/OfferCard.tsx
Normal file
6
.design-sync/previews/OfferCard.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/PacingSection.tsx
Normal file
6
.design-sync/previews/PacingSection.tsx
Normal 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 />;
|
||||
8
.design-sync/previews/PhotoUpload.tsx
Normal file
8
.design-sync/previews/PhotoUpload.tsx
Normal 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" />
|
||||
);
|
||||
8
.design-sync/previews/PlacementHistoryCard.tsx
Normal file
8
.design-sync/previews/PlacementHistoryCard.tsx
Normal 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" />
|
||||
);
|
||||
8
.design-sync/previews/PriceHistoryChart.tsx
Normal file
8
.design-sync/previews/PriceHistoryChart.tsx
Normal 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} />
|
||||
);
|
||||
24
.design-sync/previews/PriceRangeBar.tsx
Normal file
24
.design-sync/previews/PriceRangeBar.tsx
Normal 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} />);
|
||||
5
.design-sync/previews/PriceTrendCard.tsx
Normal file
5
.design-sync/previews/PriceTrendCard.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/ProviderProxySection.tsx
Normal file
6
.design-sync/previews/ProviderProxySection.tsx
Normal 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" />;
|
||||
8
.design-sync/previews/RecentSoldList.tsx
Normal file
8
.design-sync/previews/RecentSoldList.tsx
Normal 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} />
|
||||
);
|
||||
36
.design-sync/previews/ResultPanel.tsx
Normal file
36
.design-sync/previews/ResultPanel.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { ResultPanel } from 'tradein-mvp-frontend';
|
||||
|
||||
/** Переиспользуемая панель результата мутации скрапера. Успех → JSON-дамп
|
||||
* ответа в `.scraper-result`. */
|
||||
export const Default = () => (
|
||||
<ResultPanel
|
||||
mut={{
|
||||
isSuccess: true,
|
||||
isPending: false,
|
||||
error: null,
|
||||
data: {
|
||||
run_id: 4821,
|
||||
source: 'avito',
|
||||
status: 'completed',
|
||||
anchors_seen: 64,
|
||||
lots_scraped: 312,
|
||||
houses_matched: 47,
|
||||
detail_fetched: 298,
|
||||
errors: 0,
|
||||
started_at: '2026-05-30T09:12:00Z',
|
||||
finished_at: '2026-05-30T09:48:21Z',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Ветка ошибки — красная панель `.scraper-result--error` с текстом сбоя. */
|
||||
export const Error_ = () => (
|
||||
<ResultPanel
|
||||
mut={{
|
||||
isSuccess: false,
|
||||
isPending: false,
|
||||
error: new Error('502 Bad Gateway: upstream avito proxy timeout'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
15
.design-sync/previews/RouteGuard.tsx
Normal file
15
.design-sync/previews/RouteGuard.tsx
Normal 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>
|
||||
);
|
||||
6
.design-sync/previews/RunsTable.tsx
Normal file
6
.design-sync/previews/RunsTable.tsx
Normal 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" />;
|
||||
25
.design-sync/previews/ScheduleControl.tsx
Normal file
25
.design-sync/previews/ScheduleControl.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
6
.design-sync/previews/SellTimeSensitivity.tsx
Normal file
6
.design-sync/previews/SellTimeSensitivity.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { SellTimeSensitivity } from 'tradein-mvp-frontend';
|
||||
import { FIXTURE_SELLTIME } from './_fixtures';
|
||||
|
||||
/** «Срок продажи в зависимости от цены» — 4 бакета премии (−5% / медиана / +5% /
|
||||
* +10%) с медианой экспозиции и p25–p75. Принимает data-пропом напрямую. */
|
||||
export const Default = () => <SellTimeSensitivity data={FIXTURE_SELLTIME} />;
|
||||
6
.design-sync/previews/SourcesProgress.tsx
Normal file
6
.design-sync/previews/SourcesProgress.tsx
Normal 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} />;
|
||||
8
.design-sync/previews/StreetDealsCard.tsx
Normal file
8
.design-sync/previews/StreetDealsCard.tsx
Normal 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} />;
|
||||
6
.design-sync/previews/SystemHealthSection.tsx
Normal file
6
.design-sync/previews/SystemHealthSection.tsx
Normal 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 />;
|
||||
5
.design-sync/previews/TestPresets.tsx
Normal file
5
.design-sync/previews/TestPresets.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { TestPresets } from 'tradein-mvp-frontend';
|
||||
|
||||
/** Быстрое заполнение формы тестовыми квартирами ЕКБ — сетка из 6 preset-чипов
|
||||
* (адрес + подсказка по покрытию). Self-contained (`<style>` внутри). */
|
||||
export const Default = () => <TestPresets onPick={() => {}} />;
|
||||
6
.design-sync/previews/Topbar.tsx
Normal file
6
.design-sync/previews/Topbar.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Topbar } from 'tradein-mvp-frontend';
|
||||
|
||||
/** Шапка приложения «Мера» — бренд-марка + навигация. useMe/useBrand офлайн
|
||||
* не резолвятся → fallback показывает полный набор nav-айтемов; UserMenu сам
|
||||
* рендерит null. Активная вкладка — «Оценка». */
|
||||
export const Default = () => <Topbar active="estimate" />;
|
||||
10
.design-sync/previews/UserMenu.tsx
Normal file
10
.design-sync/previews/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
9
.design-sync/previews/WhatIfPanel.tsx
Normal file
9
.design-sync/previews/WhatIfPanel.tsx
Normal 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} />
|
||||
);
|
||||
3
.design-sync/previews/_fixtures.ts
Normal file
3
.design-sync/previews/_fixtures.ts
Normal 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';
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -90,3 +90,8 @@ data/osrm/*
|
|||
|
||||
# Log cruft at repo root
|
||||
debug.log
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue