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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-node
|
||||
.vite
|
||||
.tsbuildinfo
|
||||
.tsbuildinfo-node
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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="חיפוש עסקאות נדל"ן והחלטות שמאי מכריע באזור ספציפי" />
|
||||
<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>
|
||||
Generated
+3514
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+242
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user