Add: Web UI (React 19 + Vite + shadcn/ui) + FastAPI REST layer

Adds a self-contained web interface that wraps the MCP tools so end users
don't need to issue MCP commands. Single Docker container serves:

  /              → React SPA (RTL Hebrew, Heebo font, shadcn/ui components)
  /api/*         → FastAPI REST endpoints used by the SPA
  /api/docs      → OpenAPI / Swagger
  /mcp           → existing FastMCP streamable-http endpoint
  /health        → liveness probe

REST endpoints (in nadlan_mcp/web_app.py):
  GET  /api/search/address?q=...  — autocomplete + best-effort gush/חלקה
                                    via Govmap PARCEL_ALL layer
  POST /api/search/deals          — Govmap deals around an address
  POST /api/search/appraisals     — Decisive-appraiser search (gov.il)
  GET  /api/appraisals/pdf?url=.. — Stream PDFs through this server,
                                    carrying the upstream x-client-id
                                    header (a normal browser fetch fails)

UI flow:
  - Address search → resolves gush/חלקה → shows BOTH deals (Govmap) AND
    appraiser decisions (gov.il) for the same parcel side-by-side in tabs.
  - Block+plot search → goes straight to appraisals.
  - Appraiser-name search → all decisions by that appraiser.

Stack chosen for RTL-first usability and longevity: React 19, Vite 6,
TypeScript, Tailwind, shadcn/ui (Radix primitives), TanStack Query,
Lucide icons. ~295KB gzipped JS, no SSR overhead.

Multi-stage Dockerfile: Node 22 builds web/ in stage 1, Python 3.13
runtime serves the dist + the FastAPI app in stage 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:35:08 +00:00
parent 4bc054f315
commit 5e9963f51b
29 changed files with 5152 additions and 59 deletions
+8
View File
@@ -84,6 +84,14 @@ requirements-dev.txt
tmp/ tmp/
temp/ temp/
# Web build outputs (rebuilt fresh in Docker stage 1)
web/node_modules/
web/dist/
web/.vite/
web/.tsbuildinfo
web/.tsbuildinfo-node
web/dist-node/
# Docker files (avoid recursive copies) # Docker files (avoid recursive copies)
Dockerfile* Dockerfile*
docker-compose*.yml docker-compose*.yml
+26 -22
View File
@@ -1,45 +1,49 @@
# Dockerfile for Nadlan-MCP HTTP Server # ─── Stage 1: build the React UI ────────────────────────────────────────
# Supports deployment to Render, Railway, Docker, and other container platforms FROM node:22-alpine AS web-build
WORKDIR /web
# Cache deps separately from sources.
COPY web/package.json web/package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY web/ .
RUN npm run build
# ─── Stage 2: Python runtime ────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.13-slim
# Set environment variables
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# Set working directory
WORKDIR /app WORKDIR /app
# Install system dependencies if needed # Build deps for curl_cffi (needs libcurl-impersonate). curl_cffi vendors
RUN apt-get update && \ # its own libs but a C toolchain is still useful for any pure-Python whls
apt-get install -y --no-install-recommends \ # without prebuilt wheels for slim.
gcc \ RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy package files for better layer caching # Install Python deps from pyproject.
COPY pyproject.toml . COPY pyproject.toml README.md ./
COPY README.md .
# Install Python dependencies
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
# Copy application code # App code.
COPY nadlan_mcp/ ./nadlan_mcp/ COPY nadlan_mcp/ ./nadlan_mcp/
COPY run_http_server.py . COPY run_http_server.py ./
# Create non-root user for security # React build artifacts → web/dist (where web_app.py looks for them).
RUN useradd -m -u 1000 appuser && \ COPY --from=web-build /web/dist ./web/dist
chown -R appuser:appuser /app
# Non-root user.
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser USER appuser
# Expose port (will be overridden by PORT env var in production)
EXPOSE 8000 EXPOSE 8000
# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8000}/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8000}/health')" || exit 1
# Run the HTTP server
CMD ["python", "run_http_server.py"] CMD ["python", "run_http_server.py"]
+413
View File
@@ -0,0 +1,413 @@
"""
Web application: FastAPI REST endpoints + FastMCP mount + React static.
Layout:
/ → React SPA (index.html + assets)
/api/... → FastAPI REST endpoints used by the React UI
/api/docs → Swagger UI for the REST API
/mcp → FastMCP streamable-http endpoint (for LLM clients)
/health → liveness probe (also mounted under /api/health)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.responses import FileResponse, JSONResponse
from nadlan_mcp.fastmcp_server import (
client as govmap_client,
)
from nadlan_mcp.fastmcp_server import (
decisive_appraiser_client,
mcp,
)
from nadlan_mcp.govil.client import ALLOWED_PDF_HOSTS
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────
# Request / response schemas
# ──────────────────────────────────────────────────────────────────────
class DealsSearchRequest(BaseModel):
address: str = Field(..., min_length=2, max_length=200)
years_back: int = Field(2, ge=1, le=10)
radius_meters: int = Field(100, ge=10, le=2000)
max_deals: int = Field(50, ge=1, le=200)
deal_type: int = Field(2, ge=1, le=2)
class AppraisalsSearchRequest(BaseModel):
block: str | None = Field(None, max_length=20)
plot: str | None = Field(None, max_length=20)
decisive_appraiser: str | None = Field(None, max_length=100)
committee: str | None = Field(None, max_length=100)
decision_date_from: str | None = Field(None, max_length=20)
decision_date_to: str | None = Field(None, max_length=20)
publicity_date_from: str | None = Field(None, max_length=20)
publicity_date_to: str | None = Field(None, max_length=20)
search_text: str | None = Field(None, max_length=200)
appraisal_header: str | None = Field(None, max_length=200)
max_results: int = Field(30, ge=1, le=100)
# ──────────────────────────────────────────────────────────────────────
# App factory
# ──────────────────────────────────────────────────────────────────────
def create_app() -> FastAPI:
"""
Build the combined FastAPI + FastMCP application.
The static-file mount at "/" must be the LAST mount so that more
specific paths (/api, /mcp, /health) take precedence.
"""
# FastMCP needs its lifespan to run, so we forward it to FastAPI.
if hasattr(mcp, "streamable_http_app"):
mcp_app = mcp.streamable_http_app()
elif hasattr(mcp, "http_app"):
mcp_app = mcp.http_app()
else:
raise RuntimeError("FastMCP version does not expose an HTTP app")
app = FastAPI(
title="Nadlan-MCP Web",
description=(
"REST API for Israeli real-estate (Govmap) and decisive-appraiser "
"(Ministry of Justice) data. Powers the React UI; also exposes the "
"raw FastMCP endpoint at /mcp for LLM clients."
),
version="2.1.0",
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=mcp_app.router.lifespan_context,
)
# CORS — only relevant during local dev when Vite serves on :5173.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
# ── Health ────────────────────────────────────────────────────────
@app.get("/api/health")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "nadlan-mcp"}
# ── /api/search/address ───────────────────────────────────────────
@app.get("/api/search/address")
async def search_address(q: str = Query(..., min_length=2, max_length=120)):
"""Autocomplete an Israeli address. Also returns coordinates and (when
available) the gush/חלקה for the top match."""
try:
response = govmap_client.autocomplete_address(q)
except Exception as e:
logger.exception("autocomplete_address failed")
raise HTTPException(status_code=502, detail=str(e))
results = []
for r in response.results:
item: dict[str, Any] = {
"text": r.text,
"type": r.type,
"score": r.score,
"id": r.id,
}
if r.coordinates:
item["coordinates"] = {
"longitude": r.coordinates.longitude,
"latitude": r.coordinates.latitude,
}
results.append(item)
# Best-effort gush/חלקה for the top result. Use PARCEL_ALL layer
# explicitly — the default layer 16 in get_gush_helka is `nadlan`
# (deals) which has null gush/parcel for many addresses.
gush_helka = None
if response.results and response.results[0].coordinates:
try:
coords = response.results[0].coordinates
gh = _query_parcel_layer(govmap_client, coords.longitude, coords.latitude)
gush_helka = _extract_gush_helka(gh)
except Exception as e:
logger.warning(f"parcel lookup failed for '{q}': {e}")
return {"query": q, "results": results, "gush_helka": gush_helka}
# ── /api/search/deals ─────────────────────────────────────────────
@app.post("/api/search/deals")
async def search_deals(req: DealsSearchRequest):
"""Find recent real-estate deals around an address (Govmap)."""
try:
deals = govmap_client.find_recent_deals_for_address(
req.address,
req.years_back,
req.radius_meters,
req.max_deals,
req.deal_type,
)
except Exception as e:
logger.exception("find_recent_deals_for_address failed")
raise HTTPException(status_code=502, detail=str(e))
# Search-center coords for the map (best-effort).
search_coords = None
try:
ar = govmap_client.autocomplete_address(req.address)
if ar.results and ar.results[0].coordinates:
c = ar.results[0].coordinates
search_coords = {"longitude": c.longitude, "latitude": c.latitude}
except Exception:
pass
# Strip bloat to keep payload small for the UI.
bloat = {
"shape",
"sourceorder",
"objectid",
"priority",
"source_polygon_id",
"settlementId",
"streetCode",
"dealId",
"polygonId",
}
deal_dicts = []
for idx, deal in enumerate(deals, start=1):
d = deal.model_dump(mode="json", exclude_none=True)
d = {k: v for k, v in d.items() if k not in bloat}
d["id"] = idx
d["deal_source"] = getattr(deal, "deal_source", None)
deal_dicts.append(d)
return {
"search": {
"address": req.address,
"coordinates": search_coords,
"years_back": req.years_back,
"radius_meters": req.radius_meters,
"deal_type": req.deal_type,
},
"total": len(deal_dicts),
"deals": deal_dicts,
}
# ── /api/search/appraisals ────────────────────────────────────────
@app.post("/api/search/appraisals")
async def search_appraisals(req: AppraisalsSearchRequest):
"""Search decisive-appraiser decisions (gov.il / Ministry of Justice)."""
try:
response = decisive_appraiser_client.search_decisions_paged(
max_results=req.max_results,
block=req.block,
plot=req.plot,
decisive_appraiser=req.decisive_appraiser,
committee=req.committee,
decision_date_from=req.decision_date_from,
decision_date_to=req.decision_date_to,
publicity_date_from=req.publicity_date_from,
publicity_date_to=req.publicity_date_to,
search_text=req.search_text,
appraisal_header=req.appraisal_header,
)
except Exception as e:
logger.exception("search_decisions_paged failed")
raise HTTPException(status_code=502, detail=str(e))
decisions = []
for idx, decision in enumerate(response.results, start=1):
d = decision.model_dump(mode="json", exclude_none=True)
documents = d.pop("documents", []) or []
d["id"] = idx
d["pdf_url"] = documents[0]["file_url"] if documents else None
d["all_documents"] = documents
decisions.append(d)
return {
"total_in_db": response.total_results,
"returned": len(decisions),
"filters": req.model_dump(exclude_none=True),
"decisions": decisions,
}
# ── /api/appraisals/pdf ───────────────────────────────────────────
@app.get("/api/appraisals/pdf")
async def proxy_appraisal_pdf(url: str = Query(..., min_length=20)):
"""
Stream a decisive-appraiser PDF through this server.
Required because the upstream PDF host demands the same x-client-id
header as the search API; a normal browser fetch would fail.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.hostname not in ALLOWED_PDF_HOSTS:
raise HTTPException(status_code=400, detail="URL host not allowed")
try:
# Reuse the curl_cffi session that already carries x-client-id.
response = decisive_appraiser_client._session.get(
url,
timeout=(
decisive_appraiser_client.config.connect_timeout,
decisive_appraiser_client.config.read_timeout * 4,
),
headers={"Accept": "application/pdf,*/*"},
)
except Exception as e:
logger.exception("PDF proxy upstream call failed")
raise HTTPException(status_code=502, detail=str(e))
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=f"Upstream returned {response.status_code}",
)
if not response.content.startswith(b"%PDF"):
raise HTTPException(status_code=502, detail="Upstream did not return a PDF")
# Suggest a reasonable filename for the browser.
filename = parsed.path.rstrip("/").split("/")[-1] or "appraisal"
if not filename.lower().endswith(".pdf"):
filename = f"{filename}.pdf"
def _iter() -> Any:
yield response.content
return StreamingResponse(
_iter(),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
"Cache-Control": "private, max-age=3600",
},
)
# ── /mcp/ → FastMCP ───────────────────────────────────────────────
app.mount("/mcp", mcp_app)
# ── / → React static (must be LAST) ───────────────────────────────
web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
if web_dist.is_dir():
app.mount(
"/assets",
StaticFiles(directory=web_dist / "assets"),
name="assets",
)
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
# Unknown paths fall back to index.html so React Router can take over.
index = web_dist / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse({"detail": "UI not built"}, status_code=404)
else:
logger.warning(
f"Web build directory not found at {web_dist}; UI will not be served. "
f"Run `cd web && pnpm build` to populate it."
)
@app.get("/", include_in_schema=False)
async def no_ui():
return JSONResponse(
{
"service": "nadlan-mcp",
"ui": "not built",
"api_docs": "/api/docs",
"mcp": "/mcp",
}
)
return app
# ──────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────
def _query_parcel_layer(client: Any, longitude: float, latitude: float) -> dict[str, Any]:
"""
Query Govmap's PARCEL_ALL layer at a point. Returns the raw JSON.
The default `client.get_gush_helka` queries layer 16 (`nadlan`), which has
null gush/parcel for many address points. PARCEL_ALL is the cadastral layer
that reliably exposes them.
"""
url = f"{client.base_url}/layers-catalog/entitiesByPoint"
payload = {
"point": [longitude, latitude],
"layers": [{"layerId": "PARCEL_ALL"}],
"tolerance": 5,
}
timeout = (client.config.connect_timeout, client.config.read_timeout)
response = client.session.post(url, json=payload, timeout=timeout)
response.raise_for_status()
return response.json()
def _extract_gush_helka(govmap_response: Any) -> dict[str, Any] | None:
"""
Pull the (gush, helka) pair out of the layered Govmap response.
Real shape (layerId 16 / nadlan layer):
{"data": [{"name": "...", "entities": [
{"fields": [{"fieldName": "גוש", "fieldValue": "6212"},
{"fieldName": "חלקה", "fieldValue": "894"}, ...]}
]}]}
Values can be null when the layer has no data at that point — we skip
those and keep looking through every entity in every layer.
"""
if not isinstance(govmap_response, dict):
return None
block: str | None = None
plot: str | None = None
for layer in govmap_response.get("data") or []:
if not isinstance(layer, dict):
continue
for entity in layer.get("entities") or []:
if not isinstance(entity, dict):
continue
for f in entity.get("fields") or []:
name = (f.get("fieldName") or "").strip()
value = f.get("fieldValue")
if value is None:
continue
value = str(value).strip()
if not value:
continue
if (
name in ("גוש", "מספר גוש", "gush", "block", "GUSH_NUM", "gush_num")
and not block
):
block = value
elif (
name in ("חלקה", "מספר חלקה", "helka", "parcel", "PARCEL", "PARCEL_NUM")
and not plot
):
plot = value
if block and plot:
return {"block": block, "plot": plot}
if block or plot:
return {"block": block, "plot": plot}
return None
+2
View File
@@ -28,6 +28,8 @@ dependencies = [
"pydantic>=2.0.0", "pydantic>=2.0.0",
"fastmcp>=2.13.0,<3.0.0", "fastmcp>=2.13.0,<3.0.0",
"curl_cffi>=0.5.0", "curl_cffi>=0.5.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+17 -37
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
HTTP Server Entry Point for Nadlan-MCP HTTP Server Entry Point for Nadlan-MCP.
This module provides an HTTP/SSE transport server for the Nadlan-MCP service, Serves three things on a single port:
enabling deployment to cloud platforms like Render, Railway, or Docker containers. / → React SPA (search & browse Israeli real estate data)
/api/... → REST endpoints used by the SPA (also documented at /api/docs)
/mcp → FastMCP streamable-http endpoint (for LLM clients)
For local Claude Desktop integration, use run_fastmcp_server.py (stdio transport) instead. For local Claude Desktop integration, use run_fastmcp_server.py (stdio).
""" """
import logging import logging
@@ -14,52 +16,30 @@ import sys
import uvicorn import uvicorn
from nadlan_mcp.fastmcp_server import mcp from nadlan_mcp.web_app import create_app
# Configure logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Module-level app for ASGI servers (uvicorn web_app:app, gunicorn, etc.).
app = create_app()
def main():
"""Run the FastMCP server with HTTP transport using uvicorn.""" def main() -> None:
# Get port from environment variable (Render sets PORT automatically)
port = int(os.environ.get("PORT", 8000)) port = int(os.environ.get("PORT", 8000))
host = "0.0.0.0" host = "0.0.0.0"
logger.info("=" * 60) logger.info("=" * 60)
logger.info("Starting Nadlan-MCP HTTP Server") logger.info("Starting Nadlan-MCP HTTP Server")
logger.info("=" * 60) logger.info("=" * 60)
logger.info("Transport: HTTP (via uvicorn)") logger.info(f" UI: http://{host}:{port}/")
logger.info(f"Host: {host}") logger.info(f" API docs: http://{host}:{port}/api/docs")
logger.info(f"Port: {port}") logger.info(f" MCP: http://{host}:{port}/mcp")
logger.info(f"MCP Endpoint: http://{host}:{port}/mcp") logger.info(f" Health: http://{host}:{port}/health")
logger.info(f"Health Check: http://{host}:{port}/health")
logger.info("=" * 60) logger.info("=" * 60)
try: try:
# Get the HTTP ASGI app from FastMCP
# Try different possible method names based on FastMCP version
if hasattr(mcp, "streamable_http_app"):
app = mcp.streamable_http_app()
elif hasattr(mcp, "http_app"):
app = mcp.http_app()
elif hasattr(mcp, "get_app"):
app = mcp.get_app()
else:
# Fallback: try to access the app directly
app = getattr(mcp, "app", None)
if app is None:
raise AttributeError(
"FastMCP instance has no HTTP app method. "
"Available methods: " + ", ".join(dir(mcp))
)
logger.info(f"Using app: {type(app).__name__}")
# Run with uvicorn
uvicorn.run(app, host=host, port=port, log_level="info") uvicorn.run(app, host=host, port=port, log_level="info")
except Exception as e: except Exception as e:
logger.error(f"Failed to start HTTP server: {e}", exc_info=True) logger.error(f"Failed to start HTTP server: {e}", exc_info=True)
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
dist-node
.vite
.tsbuildinfo
.tsbuildinfo-node
*.log
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>נדל"ן ושמאי מכריע — Marcus-Law</title>
<meta name="description" content="חיפוש עסקאות נדל&quot;ן והחלטות שמאי מכריע באזור ספציפי" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3514
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "nadlan-mcp-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --max-warnings=0"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+242
View File
@@ -0,0 +1,242 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Building2, Gavel, MapPin } from "lucide-react";
import { api } from "@/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { SearchBar, SearchQuery } from "@/components/SearchBar";
import { DealsTable } from "@/components/DealsTable";
import { AppraisalsTable } from "@/components/AppraisalsTable";
function useAddressInfo(address: string | undefined) {
return useQuery({
queryKey: ["address", address],
queryFn: () => api.searchAddress(address!),
enabled: !!address,
});
}
function useDeals(query: SearchQuery | null) {
return useQuery({
queryKey: ["deals", query],
queryFn: () =>
api.searchDeals({
address: query!.address!,
years_back: 3,
radius_meters: 100,
max_deals: 50,
}),
enabled: !!query && query.mode === "address" && !!query.address,
});
}
function useAppraisals(query: SearchQuery | null, blockOverride?: string, plotOverride?: string) {
// For "address" mode, we feed in block/plot resolved by the address lookup.
const block = query?.block || blockOverride;
const plot = query?.plot || plotOverride;
const appraiser = query?.appraiser;
return useQuery({
queryKey: ["appraisals", { block, plot, appraiser }],
queryFn: () =>
api.searchAppraisals({
block,
plot,
decisive_appraiser: appraiser,
max_results: 30,
}),
enabled: Boolean(block || appraiser),
});
}
export default function App() {
const [query, setQuery] = useState<SearchQuery | null>(null);
const addressInfo = useAddressInfo(
query?.mode === "address" ? query.address : undefined
);
const resolvedBlock = addressInfo.data?.gush_helka?.block ?? undefined;
const resolvedPlot = addressInfo.data?.gush_helka?.plot ?? undefined;
const deals = useDeals(query);
const appraisals = useAppraisals(query, resolvedBlock, resolvedPlot);
const isLoadingTopBar =
addressInfo.isFetching || deals.isFetching || appraisals.isFetching;
// Default tab: appraiser-only search → appraisals; otherwise → deals.
const defaultTab = useMemo(
() => (query?.mode === "appraiser" ? "appraisals" : "deals"),
[query?.mode]
);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white pb-20">
<header className="border-b bg-white">
<div className="container py-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Building2 className="text-primary" />
נדל"ן ושמאי מכריע
</h1>
<p className="text-sm text-muted-foreground mt-1">
חיפוש עסקאות נדל"ן והחלטות שמאי מכריע Marcus-Law
</p>
</div>
<div className="text-xs text-muted-foreground hidden sm:block">
מקורות:{" "}
<span className="font-medium">Govmap</span> ·{" "}
<span className="font-medium">משרד המשפטים</span>
</div>
</div>
</header>
<main className="container mt-8 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">חיפוש</CardTitle>
<CardDescription>
חפש לפי כתובת מלאה (להצגת עסקאות נדל"ן + החלטות שמאי באותו גוש),
לפי גוש וחלקה ספציפיים, או לפי שם של שמאי מכריע.
</CardDescription>
</CardHeader>
<CardContent>
<SearchBar onSearch={setQuery} isLoading={isLoadingTopBar} />
</CardContent>
</Card>
{!query && (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
הזן כתובת כדי להתחיל. עסקאות מתקבלות מ-Govmap, החלטות שמאי מ-gov.il.
</CardContent>
</Card>
)}
{query && (
<>
{/* Address resolution summary */}
{query.mode === "address" && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="size-4 text-primary" />
{query.address}
</CardTitle>
<CardDescription>
{addressInfo.isFetching && "מאתר את הכתובת..."}
{addressInfo.data?.gush_helka && (
<>
זוהה: גוש{" "}
<span className="font-medium">
{addressInfo.data.gush_helka.block || "?"}
</span>
{addressInfo.data.gush_helka.plot && (
<>
{" "}חלקה{" "}
<span className="font-medium">
{addressInfo.data.gush_helka.plot}
</span>
</>
)}
</>
)}
{addressInfo.data && !addressInfo.data.gush_helka && (
<span className="text-amber-700">
לא נמצאו פרטי גוש/חלקה לכתובת זו — חיפוש שמאי לפי כתובת לא יבוצע.
</span>
)}
</CardDescription>
</CardHeader>
</Card>
)}
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList>
<TabsTrigger value="deals" disabled={query.mode === "appraiser"}>
<Building2 className="ms-1 size-4" />
עסקאות נדל"ן
{deals.data && (
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
{deals.data.total}
</span>
)}
</TabsTrigger>
<TabsTrigger value="appraisals">
<Gavel className="ms-1 size-4" />
שמאי מכריע
{appraisals.data && (
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
{appraisals.data.returned}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="deals" className="mt-6">
{deals.isFetching && <TableSkeleton />}
{deals.error && (
<ErrorBox error={String(deals.error)} />
)}
{deals.data && !deals.isFetching && (
<DealsTable deals={deals.data.deals} />
)}
{!deals.isFetching && !deals.error && !deals.data && (
<div className="text-center py-12 text-muted-foreground">
חיפוש עסקאות זמין רק לפי כתובת.
</div>
)}
</TabsContent>
<TabsContent value="appraisals" className="mt-6">
{appraisals.isFetching && <TableSkeleton />}
{appraisals.error && (
<ErrorBox error={String(appraisals.error)} />
)}
{appraisals.data && !appraisals.isFetching && (
<AppraisalsTable
decisions={appraisals.data.decisions}
totalInDb={appraisals.data.total_in_db}
/>
)}
{!appraisals.isFetching && !appraisals.error && !appraisals.data && (
<div className="text-center py-12 text-muted-foreground">
{query.mode === "address"
? "ממתין לזיהוי גוש/חלקה..."
: "אין נתונים."}
</div>
)}
</TabsContent>
</Tabs>
</>
)}
</main>
</div>
);
}
function TableSkeleton() {
return (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
function ErrorBox({ error }: { error: string }) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
שגיאה: {error}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import {
AddressSearchResponse,
AppraisalsSearchRequest,
AppraisalsSearchResponse,
DealsSearchRequest,
DealsSearchResponse,
} from "./types";
const API_BASE = "/api";
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init?.headers || {}),
},
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body?.detail || detail;
} catch {
// ignore
}
throw new ApiError(res.status, detail);
}
return res.json();
}
export const api = {
searchAddress(q: string): Promise<AddressSearchResponse> {
return request(`/search/address?q=${encodeURIComponent(q)}`);
},
searchDeals(req: DealsSearchRequest): Promise<DealsSearchResponse> {
return request(`/search/deals`, {
method: "POST",
body: JSON.stringify(req),
});
},
searchAppraisals(req: AppraisalsSearchRequest): Promise<AppraisalsSearchResponse> {
return request(`/search/appraisals`, {
method: "POST",
body: JSON.stringify(req),
});
},
pdfProxyUrl(url: string): string {
return `${API_BASE}/appraisals/pdf?url=${encodeURIComponent(url)}`;
},
};
export { ApiError };
+100
View File
@@ -0,0 +1,100 @@
// Mirrors the Pydantic schemas in nadlan_mcp/web_app.py.
// Keep them in sync if you edit the backend.
export interface Coordinates {
longitude: number;
latitude: number;
}
export interface AddressResult {
text: string;
type: string;
score: number;
id: string;
coordinates?: Coordinates;
}
export interface AddressSearchResponse {
query: string;
results: AddressResult[];
gush_helka: { block: string | null; plot: string | null } | null;
}
export interface DealsSearchRequest {
address: string;
years_back?: number;
radius_meters?: number;
max_deals?: number;
deal_type?: 1 | 2;
}
export interface Deal {
id: number;
deal_amount?: number;
asset_area?: number;
rooms?: number;
floor?: number;
deal_date?: string;
property_type_description?: string;
settlementNameHeb?: string;
streetNameHeb?: string;
house_num?: string | number;
price_per_sqm?: number;
deal_source?: string | null;
[key: string]: unknown;
}
export interface DealsSearchResponse {
search: {
address: string;
coordinates: Coordinates | null;
years_back: number;
radius_meters: number;
deal_type: number;
};
total: number;
deals: Deal[];
}
export interface AppraisalsSearchRequest {
block?: string;
plot?: string;
decisive_appraiser?: string;
committee?: string;
decision_date_from?: string;
decision_date_to?: string;
publicity_date_from?: string;
publicity_date_to?: string;
search_text?: string;
appraisal_header?: string;
max_results?: number;
}
export interface AppraisalDocument {
file_url: string;
display_name?: string;
extension?: string;
}
export interface AppraisalDecision {
id: number;
appraisal_header?: string;
appraisal_type?: string;
appraisal_version?: string;
decisive_appraiser?: string;
appraiser_type?: string;
block?: string;
plot?: string;
committee?: string;
decision_date?: string;
publicity_date?: string;
pdf_url: string | null;
all_documents: AppraisalDocument[];
}
export interface AppraisalsSearchResponse {
total_in_db: number;
returned: number;
filters: Record<string, unknown>;
decisions: AppraisalDecision[];
}
+96
View File
@@ -0,0 +1,96 @@
import { Download, FileText } from "lucide-react";
import { AppraisalDecision } from "@/api/types";
import { Button } from "@/components/ui/button";
import { api } from "@/api/client";
import { formatDate } from "@/lib/utils";
interface Props {
decisions: AppraisalDecision[];
totalInDb: number;
}
export function AppraisalsTable({ decisions, totalInDb }: Props) {
if (!decisions.length) {
return (
<div className="text-center py-12 text-muted-foreground">
לא נמצאו החלטות שמאי מכריע באזור הזה
</div>
);
}
return (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
נמצאו {decisions.length} מתוך {totalInDb.toLocaleString("he-IL")} החלטות במאגר
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-start font-medium">תאריך הכרעה</th>
<th className="p-3 text-start font-medium">שמאי מכריע</th>
<th className="p-3 text-start font-medium">ועדה</th>
<th className="p-3 text-start font-medium">גוש/חלקה</th>
<th className="p-3 text-start font-medium">סוג שומה</th>
<th className="p-3 text-start font-medium">כותרת ההחלטה</th>
<th className="p-3 text-end font-medium">PDF</th>
</tr>
</thead>
<tbody>
{decisions.map((d) => (
<tr key={d.id} className="border-t hover:bg-accent/40 align-top">
<td className="p-3 whitespace-nowrap">{formatDate(d.decision_date)}</td>
<td className="p-3 whitespace-nowrap">{d.decisive_appraiser || "—"}</td>
<td className="p-3 whitespace-nowrap">{d.committee || "—"}</td>
<td className="p-3 whitespace-nowrap tabular-nums">
{d.block ?? "—"}
{d.plot ? ` / ${d.plot}` : ""}
</td>
<td className="p-3">{d.appraisal_type || "—"}</td>
<td className="p-3 max-w-md">
<span className="line-clamp-2">{d.appraisal_header || "—"}</span>
</td>
<td className="p-3 text-end whitespace-nowrap">
{d.pdf_url ? (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
asChild
title="פתח PDF בלשונית חדשה"
>
<a
href={api.pdfProxyUrl(d.pdf_url)}
target="_blank"
rel="noopener noreferrer"
>
<FileText />
פתח
</a>
</Button>
<Button
size="sm"
variant="ghost"
asChild
title="הורד PDF"
>
<a
href={api.pdfProxyUrl(d.pdf_url) + "&download=1"}
download={`${d.block || "appraisal"}-${d.plot || "0"}-${d.id}.pdf`}
>
<Download />
</a>
</Button>
</div>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Deal } from "@/api/types";
import { formatCurrencyILS, formatDate, formatNumber } from "@/lib/utils";
interface Props {
deals: Deal[];
}
const SOURCE_LABELS: Record<string, string> = {
same_building: "אותו בניין",
street: "באותו רחוב",
neighborhood: "בשכונה",
};
export function DealsTable({ deals }: Props) {
if (!deals.length) {
return (
<div className="text-center py-12 text-muted-foreground">
לא נמצאו עסקאות באזור הזה
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-start font-medium">תאריך</th>
<th className="p-3 text-start font-medium">כתובת</th>
<th className="p-3 text-start font-medium">סוג</th>
<th className="p-3 text-end font-medium">חדרים</th>
<th className="p-3 text-end font-medium">קומה</th>
<th className="p-3 text-end font-medium">שטח (מ"ר)</th>
<th className="p-3 text-end font-medium">מחיר</th>
<th className="p-3 text-end font-medium">מחיר/מ"ר</th>
<th className="p-3 text-start font-medium">קרבה</th>
</tr>
</thead>
<tbody>
{deals.map((d) => {
const street = (d.streetNameHeb as string) || "";
const houseNum = (d.house_num as string | number | undefined) ?? "";
const settlement = (d.settlementNameHeb as string) || "";
const address = [street, houseNum, settlement].filter(Boolean).join(" ");
return (
<tr key={d.id} className="border-t hover:bg-accent/40">
<td className="p-3 whitespace-nowrap">{formatDate(d.deal_date)}</td>
<td className="p-3">{address || "—"}</td>
<td className="p-3">{d.property_type_description || "—"}</td>
<td className="p-3 text-end tabular-nums">{d.rooms ?? "—"}</td>
<td className="p-3 text-end tabular-nums">{d.floor ?? "—"}</td>
<td className="p-3 text-end tabular-nums">{formatNumber(d.asset_area)}</td>
<td className="p-3 text-end tabular-nums">
{formatCurrencyILS(d.deal_amount)}
</td>
<td className="p-3 text-end tabular-nums">
{formatCurrencyILS(d.price_per_sqm)}
</td>
<td className="p-3">
{d.deal_source ? (
<span
className={
"inline-block rounded-full px-2 py-0.5 text-xs " +
(d.deal_source === "same_building"
? "bg-emerald-100 text-emerald-800"
: d.deal_source === "street"
? "bg-blue-100 text-blue-800"
: "bg-slate-100 text-slate-700")
}
>
{SOURCE_LABELS[d.deal_source] || d.deal_source}
</span>
) : (
"—"
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type SearchMode = "address" | "block_plot" | "appraiser";
export interface SearchQuery {
mode: SearchMode;
address?: string;
block?: string;
plot?: string;
appraiser?: string;
}
interface Props {
onSearch: (q: SearchQuery) => void;
isLoading?: boolean;
}
export function SearchBar({ onSearch, isLoading }: Props) {
const [mode, setMode] = useState<SearchMode>("address");
const [address, setAddress] = useState("");
const [block, setBlock] = useState("");
const [plot, setPlot] = useState("");
const [appraiser, setAppraiser] = useState("");
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (mode === "address" && address.trim()) {
onSearch({ mode, address: address.trim() });
} else if (mode === "block_plot" && block.trim()) {
onSearch({ mode, block: block.trim(), plot: plot.trim() || undefined });
} else if (mode === "appraiser" && appraiser.trim()) {
onSearch({ mode, appraiser: appraiser.trim() });
}
};
return (
<form onSubmit={submit} className="space-y-4">
<div className="flex flex-wrap gap-2 text-sm">
{(
[
["address", "כתובת"],
["block_plot", "גוש וחלקה"],
["appraiser", "שמאי מכריע"],
] as const
).map(([m, label]) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={
"rounded-full border px-4 py-1.5 transition-colors " +
(mode === m
? "bg-primary text-primary-foreground border-primary"
: "bg-background text-muted-foreground hover:bg-accent")
}
>
{label}
</button>
))}
</div>
{mode === "address" && (
<div className="space-y-2">
<Label htmlFor="address">כתובת מלאה</Label>
<Input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="לדוגמה: סוקולוב 38 חולון"
autoFocus
/>
</div>
)}
{mode === "block_plot" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="block">גוש (חובה)</Label>
<Input
id="block"
value={block}
onChange={(e) => setBlock(e.target.value)}
placeholder="6212"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="plot">חלקה (אופציונלי)</Label>
<Input
id="plot"
value={plot}
onChange={(e) => setPlot(e.target.value)}
placeholder="894"
/>
</div>
</div>
)}
{mode === "appraiser" && (
<div className="space-y-2">
<Label htmlFor="appraiser">שם השמאי המכריע</Label>
<Input
id="appraiser"
value={appraiser}
onChange={(e) => setAppraiser(e.target.value)}
placeholder="לדוגמה: כהן אליהו"
autoFocus
/>
</div>
)}
<Button type="submit" disabled={isLoading} className="w-full">
<Search className="ms-1" />
{isLoading ? "מחפש..." : "חפש"}
</Button>
</form>
);
}
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 215 70% 35%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 215 70% 35%;
--radius: 0.5rem;
}
* {
@apply border-border;
}
html,
body {
@apply bg-background text-foreground antialiased;
font-family: "Heebo", system-ui, -apple-system, sans-serif;
}
/* Tailwind's default scrollbar gutters cause RTL drift; force stable. */
html {
scrollbar-gutter: stable;
}
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+54
View File
@@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
container: {
center: true,
padding: "1rem",
screens: { "2xl": "1400px" },
},
extend: {
fontFamily: {
sans: ["Heebo", "system-ui", "-apple-system", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./.tsbuildinfo"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist-node",
"tsBuildInfoFile": "./.tsbuildinfo-node"
},
"include": ["vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// During dev (`pnpm dev`), Vite serves on :5173 and proxies /api + /mcp + /health
// to the FastAPI server on :8765 (or PORT env var).
const BACKEND_PORT = process.env.BACKEND_PORT || "8765";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": `http://127.0.0.1:${BACKEND_PORT}`,
"/mcp": `http://127.0.0.1:${BACKEND_PORT}`,
"/health": `http://127.0.0.1:${BACKEND_PORT}`,
},
},
build: {
outDir: "dist",
sourcemap: false,
},
});