refactor: restructure the frontend

This commit is contained in:
Sosokker 2025-04-07 23:44:03 +07:00
parent 97734887b7
commit 7ab14fad02
62 changed files with 2865 additions and 2510 deletions

View File

@ -1,36 +1,88 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```markdown
frontend/
├── features/ # <= NEW: Feature-specific modules
│ ├── map/
│ │ ├── api/ # API calls specific to the map
│ │ │ └── mapApi.ts # (Example)
│ │ ├── components/ # Map-specific UI components
│ │ │ ├── analytics-overlay.tsx
│ │ │ ├── analytics-panel.tsx
│ │ │ ├── area-chart.tsx
│ │ │ ├── chat-bot.tsx
│ │ │ ├── chat-overlay.tsx
│ │ │ ├── filters-overlay.tsx
│ │ │ ├── map-container.tsx
│ │ │ ├── map-header.tsx
│ │ │ ├── map-sidebar.tsx
│ │ │ ├── property-filters.tsx
│ │ │ └── overlay-system/ # Overlay specific components
│ │ │ ├── overlay-context.tsx
│ │ │ ├── overlay-dock.tsx
│ │ │ └── overlay.tsx
│ │ ├── hooks/ # Map-specific hooks
│ │ │ └── useMapInteractions.ts # (Example)
│ │ ├── types/ # Map-related types/interfaces
│ │ │ └── index.ts # (Example)
│ │ └── utils/ # Map-specific utilities
│ │ └── mapHelpers.ts # (Example)
│ └── model-explanation/
│ ├── components/ # Model Explanation specific components
│ │ ├── feature-importance-chart.tsx
│ │ └── price-comparison-chart.tsx
│ ├── api/
│ │ └── explanationApi.ts # (Example)
│ └── types/
│ └── index.ts # (Example)
├── components/
│ ├── ui/ # Shadcn UI components (KEEP AS IS)
│ │ └── ... (accordion.tsx, button.tsx, etc.)
│ └── common/ # <= NEW: Shared, app-wide components
│ ├── ThemeProvider.tsx # Moved from root components
│ ├── ThemeToggle.tsx # Moved from root components
│ ├── ThemeController.tsx # Moved from root components
│ └── PageLayout.tsx # (Example new shared layout)
├── lib/ # Core utilities, constants, config
│ └── utils.ts # (Keep cn function here)
├── hooks/ # Shared, app-wide hooks
│ ├── use-toast.ts # (Keep useToast here)
│ └── use-mobile.tsx # (Keep useIsMobile here)
├── services/ # <= OPTIONAL: Centralized API layer (alternative to features/\*/api)
│ └── apiClient.ts # (Example API client setup)
├── store/ # <= OPTIONAL: Global state management (e.g., Zustand, Jotai)
│ └── mapStore.ts # (Example store)
├── types/ # <= NEW: Shared, app-wide types/interfaces
│ ├── index.ts # Barrel file for types
│ └── api.ts # Example: General API response types
├── app/ # Next.js app router (routing, layouts, pages)
│ ├── (routes)/ # Keep actual route structure here
│ │ ├── map/
│ │ │ ├── layout.tsx # Imports common layout, feature components
│ │ │ └── page.tsx # Imports components from 'features/map/components'
│ │ ├── model-explanation/
│ │ │ └── page.tsx # Imports components from 'features/model-explanation/components'
│ │ └── page.tsx # Root page
│ ├── layout.tsx # Root layout (includes ThemeProvider)
│ ├── globals.css
│ └── favicon.ico
├── public/ # Static assets
│ └── ...
├── .gitignore
├── components.json
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,703 @@
/*
========================================
File: frontend/app/(routes)/model-explanation/page.tsx
========================================
*/
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import {
ChevronRight,
Info,
ArrowRight,
Home,
Building,
Ruler,
Calendar,
Coins,
Droplets,
Wind,
Sun,
Car,
School,
ShoppingBag,
} from "lucide-react";
// Common UI components
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Slider } from "@/components/ui/slider";
import { Skeleton } from "@/components/ui/skeleton";
// Removed SidebarProvider & ThemeProvider - should be in root layout
// Removed MapSidebar - assuming it's not needed here or use a common one
// Feature-specific components
import { FeatureImportanceChart } from "@/features/model-explanation/components/feature-importance-chart";
import { PriceComparisonChart } from "@/features/model-explanation/components/price-comparison-chart";
// Feature-specific API and types
import { fetchModelExplanation } from "@/features/model-explanation/api/explanationApi";
import type { ModelExplanationData, PropertyBaseDetails } from "@/features/model-explanation/types";
export default function ModelExplanationPage() {
const [activeStep, setActiveStep] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [explanationData, setExplanationData] = useState<ModelExplanationData | null>(null);
// State for interactive elements based on fetched data
const [propertySize, setPropertySize] = useState<number>(0);
const [propertyAge, setPropertyAge] = useState<number>(0);
// Fetch data on mount
useEffect(() => {
async function loadExplanation() {
setIsLoading(true);
// TODO: Get actual property ID from route params or state
const propertyId = "dummy-prop-123";
const response = await fetchModelExplanation(propertyId);
if (response.success && response.data) {
setExplanationData(response.data);
// Initialize sliders with fetched data
setPropertySize(response.data.propertyDetails.size);
setPropertyAge(response.data.propertyDetails.age);
} else {
console.error("Failed to load model explanation:", response.error);
// Handle error state in UI
}
setIsLoading(false);
}
loadExplanation();
}, []);
// Stepper configuration
const steps = [
{ id: 1, title: "Property Details", icon: Home },
{ id: 2, title: "Feature Analysis", icon: Ruler },
{ id: 3, title: "Market Comparison", icon: Building },
{ id: 4, title: "Environmental Factors", icon: Droplets },
{ id: 5, title: "Final Prediction", icon: Coins },
];
// Calculate adjusted price based on slider interaction
const calculateAdjustedPrice = () => {
if (!explanationData) return 0;
// Simple formula for demonstration - refine with actual logic if possible
const basePrice = explanationData.propertyDetails.predictedPrice;
const baseSize = explanationData.propertyDetails.size;
const baseAge = explanationData.propertyDetails.age;
const sizeImpact = (propertySize - baseSize) * 50000; // 50,000 THB per sqm diff
const ageImpact = (baseAge - propertyAge) * 200000; // 200,000 THB per year newer
return basePrice + sizeImpact + ageImpact;
};
const adjustedPrice = explanationData ? calculateAdjustedPrice() : 0;
// Loading State UI
if (isLoading) {
return (
<div className="flex flex-1 flex-col overflow-hidden p-6">
<div className="mx-auto w-full max-w-7xl">
<Skeleton className="h-10 w-1/3 mb-2" />
<Skeleton className="h-4 w-2/3 mb-8" />
<Skeleton className="h-10 w-full mb-4" />
<Skeleton className="h-2 w-full mb-8" />
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<div className="md:col-span-2 space-y-6">
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
</div>
);
}
// Error State UI
if (!explanationData) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-6">
<p className="text-destructive">Failed to load model explanation.</p>
<Link href="/map">
<Button variant="link" className="mt-4">
Back to Map
</Button>
</Link>
</div>
);
}
// Main Content UI
const { propertyDetails, features, similarProperties, environmentalFactors, confidence, priceRange } =
explanationData;
return (
// Assuming ThemeProvider is in the root layout
// Assuming SidebarProvider and a common sidebar are in root layout or parent layout
<div className="flex flex-1 flex-col overflow-hidden">
{" "}
{/* Adjusted for page content */}
{/* Header */}
<header className="flex h-14 items-center justify-between border-b px-4 bg-background flex-shrink-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/map" className="hover:text-foreground">
Map
</Link>
<ChevronRight className="h-4 w-4" />
<span className="font-medium text-foreground">Price Prediction Model</span>
</div>
{/* Add any specific header actions if needed */}
</header>
{/* Main content */}
<div className="flex-1 overflow-auto p-6">
{" "}
{/* Make content area scrollable */}
<div className="mx-auto w-full max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Explainable Price Prediction Model</h1>
<p className="mt-2 text-muted-foreground">
Understand how our AI model predicts property prices and what factors influence the valuation.
</p>
</div>
{/* Steps navigation */}
<div className="mb-8">
<div className="flex flex-wrap gap-4">
{steps.map((step) => (
<Button
key={step.id}
variant={activeStep === step.id ? "default" : "outline"}
className="flex items-center gap-2"
onClick={() => setActiveStep(step.id)}>
<step.icon className="h-4 w-4" />
<span>{step.title}</span>
</Button>
))}
</div>
<div className="mt-4">
<Progress value={(activeStep / steps.length) * 100} className="h-2" />
</div>
</div>
{/* Step content */}
<div className="grid gap-6 md:grid-cols-3">
{/* --- Left Column: Property Details & Interaction --- */}
<div className="space-y-6 md:sticky md:top-6">
{" "}
{/* Make left column sticky */}
<Card>
<CardHeader>
<CardTitle>Property Details</CardTitle>
<CardDescription>{propertyDetails.address}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{/* Dynamically display details */}
<DetailRow label="Type" value={propertyDetails.type} />
<DetailRow label="Size" value={`${propertySize} sqm`} />
{propertyDetails.bedrooms && <DetailRow label="Bedrooms" value={propertyDetails.bedrooms} />}
{propertyDetails.bathrooms && <DetailRow label="Bathrooms" value={propertyDetails.bathrooms} />}
<DetailRow label="Age" value={`${propertyAge} years`} />
{propertyDetails.floor && <DetailRow label="Floor" value={propertyDetails.floor} />}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Adjust Parameters</CardTitle>
<CardDescription>See how changes affect the prediction</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Size Slider */}
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="prop-size-slider" className="text-sm font-medium">
Property Size
</Label>
<span className="text-sm text-muted-foreground">{propertySize} sqm</span>
</div>
<Slider
id="prop-size-slider"
value={[propertySize]}
min={50} // Example range
max={300} // Example range
step={5}
onValueChange={(value) => setPropertySize(value[0])}
/>
</div>
{/* Age Slider */}
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="prop-age-slider" className="text-sm font-medium">
Property Age
</Label>
<span className="text-sm text-muted-foreground">{propertyAge} years</span>
</div>
<Slider
id="prop-age-slider"
value={[propertyAge]}
min={0} // Example range
max={50} // Example range
step={1}
onValueChange={(value) => setPropertyAge(value[0])}
/>
</div>
</CardContent>
<CardFooter>
<div className="w-full">
<div className="flex justify-between items-baseline">
<span className="text-sm text-muted-foreground">Adjusted Price</span>
<span className="text-xl font-bold">{formatCurrency(adjustedPrice)}</span>
</div>
{/* Show difference */}
{propertyDetails.predictedPrice !== adjustedPrice && (
<div className="mt-2 text-xs text-muted-foreground">
{adjustedPrice > propertyDetails.predictedPrice ? "↑" : "↓"}
{Math.abs(adjustedPrice - propertyDetails.predictedPrice).toLocaleString()} THB from original
</div>
)}
</div>
</CardFooter>
</Card>
</div>
{/* --- Right Column: Step Content --- */}
<div className="md:col-span-2 space-y-6">
{activeStep === 1 && <Step1Content propertyDetails={propertyDetails} setActiveStep={setActiveStep} />}
{activeStep === 2 && <Step2Content features={features} setActiveStep={setActiveStep} />}
{activeStep === 3 && (
<Step3Content
property={propertyDetails}
comparisons={similarProperties}
setActiveStep={setActiveStep}
/>
)}
{activeStep === 4 && <Step4Content factors={environmentalFactors} setActiveStep={setActiveStep} />}
{activeStep === 5 && (
<Step5Content
predictedPrice={adjustedPrice}
confidence={confidence}
priceRange={priceRange}
setActiveStep={setActiveStep}
/>
)}
</div>
</div>
</div>
</div>
</div>
);
}
// --- Helper Components for Steps ---
function DetailRow({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
);
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
// Step 1 Component
function Step1Content({
propertyDetails,
setActiveStep,
}: {
propertyDetails: PropertyBaseDetails;
setActiveStep: (step: number) => void;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Property Overview</CardTitle>
<CardDescription>Basic information used in our prediction model</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p>
Our AI model begins by analyzing the core attributes of your property. These fundamental characteristics
form the baseline for our prediction.
</p>
<div className="grid gap-4 md:grid-cols-2">
<InfoCard
icon={Home}
title="Property Type"
description={`${propertyDetails.type} properties in this area have specific market dynamics`}
/>
<InfoCard
icon={Ruler}
title="Size & Layout"
description={`${propertyDetails.size} sqm${
propertyDetails.bedrooms ? ` with ${propertyDetails.bedrooms} beds` : ""
}${propertyDetails.bathrooms ? ` and ${propertyDetails.bathrooms} baths` : ""}`}
/>
<InfoCard
icon={Calendar}
title="Property Age"
description={`Built ${propertyDetails.age} years ago, affecting depreciation calculations`}
/>
{propertyDetails.floor && (
<InfoCard
icon={Building}
title="Floor & View"
description={`Located on floor ${propertyDetails.floor}, impacting value`}
/>
)}
</div>
</div>
</CardContent>
<StepFooter onNext={() => setActiveStep(2)} />
</Card>
);
}
// Step 2 Component
function Step2Content({
features,
setActiveStep,
}: {
features: ModelExplanationData["features"];
setActiveStep: (step: number) => void;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Feature Analysis</CardTitle>
<CardDescription>How different features impact the predicted price</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
Our model analyzes various features and determines how each contributes to the price prediction. Below is a
breakdown of the most important factors.
</p>
<div className="h-[300px]">
<FeatureImportanceChart features={features} />
</div>
<div className="space-y-4">
{features.map((feature) => (
<div key={feature.name} className="space-y-1">
<div className="flex justify-between">
<span className="text-sm font-medium">{feature.name}</span>
<span
className={`text-sm font-semibold ${
feature.impact === "positive"
? "text-green-600 dark:text-green-400"
: feature.impact === "negative"
? "text-red-600 dark:text-red-400"
: "text-yellow-600 dark:text-yellow-400"
}`}>
{feature.impact === "positive"
? "↑ Positive"
: feature.impact === "negative"
? "↓ Negative"
: "→ Neutral"}
</span>
</div>
<div className="flex items-center gap-2">
<Progress
value={feature.importance}
className="h-2"
aria-label={`${feature.name} importance ${feature.importance}%`}
/>
<span className="text-sm text-muted-foreground">{feature.importance}%</span>
</div>
<p className="text-xs text-muted-foreground">{feature.value}</p>
</div>
))}
</div>
</div>
</CardContent>
<StepFooter onPrev={() => setActiveStep(1)} onNext={() => setActiveStep(3)} />
</Card>
);
}
// Step 3 Component
function Step3Content({
property,
comparisons,
setActiveStep,
}: {
property: PropertyBaseDetails;
comparisons: ModelExplanationData["similarProperties"];
setActiveStep: (step: number) => void;
}) {
// Prepare data for the chart, ensuring the main property is clearly labeled
const chartProperty = {
name: "Your Property",
price: property.predictedPrice,
size: property.size,
age: property.age,
};
const chartComparisons = comparisons.map((p, i) => ({
name: `Comp ${i + 1}`,
address: p.address,
price: p.price,
size: p.size,
age: p.age,
}));
return (
<Card>
<CardHeader>
<CardTitle>Market Comparison</CardTitle>
<CardDescription>
How your property compares to similar properties recently analyzed or sold in the area
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
We analyze recent data from similar properties to establish a baseline. This ensures our prediction aligns
with current market conditions.
</p>
<div className="h-[300px]">
<PriceComparisonChart property={chartProperty} comparisons={chartComparisons} />
</div>
<div className="space-y-4">
<h4 className="font-medium">Similar Properties Details</h4>
<div className="grid gap-4 md:grid-cols-3">
{comparisons.map((p, index) => (
<div key={index} className="rounded-lg border p-3 text-xs">
<div className="font-medium truncate" title={p.address}>
{p.address}
</div>
<div className="mt-1 text-muted-foreground">
{p.size} sqm, {p.age} years old
</div>
<div className="mt-2 font-bold">{formatCurrency(p.price)}</div>
</div>
))}
</div>
</div>
</div>
</CardContent>
<StepFooter onPrev={() => setActiveStep(2)} onNext={() => setActiveStep(4)} />
</Card>
);
}
// Step 4 Component
function Step4Content({
factors,
setActiveStep,
}: {
factors: ModelExplanationData["environmentalFactors"];
setActiveStep: (step: number) => void;
}) {
const factorDetails = {
floodRisk: {
icon: Droplets,
color: factors.floodRisk === "low" ? "green" : factors.floodRisk === "moderate" ? "yellow" : "red",
text: "Historical data suggests this level of risk.",
},
airQuality: {
icon: Wind,
color: factors.airQuality === "good" ? "green" : factors.airQuality === "moderate" ? "yellow" : "red",
text: "Compared to city average.",
},
noiseLevel: {
icon: Sun,
color: factors.noiseLevel === "low" ? "green" : factors.noiseLevel === "moderate" ? "yellow" : "red",
text: "Based on proximity to major roads/sources.",
},
};
return (
<Card>
<CardHeader>
<CardTitle>Environmental & Location Factors</CardTitle>
<CardDescription>How surrounding conditions and amenities affect value</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
Environmental conditions and nearby amenities significantly impact desirability and value. Our model
considers these external factors.
</p>
<div className="grid gap-4 md:grid-cols-3">
{/* Environmental Factors */}
<FactorCard title="Flood Risk" factor={factors.floodRisk} details={factorDetails.floodRisk} />
<FactorCard title="Air Quality" factor={factors.airQuality} details={factorDetails.airQuality} />
<FactorCard title="Noise Level" factor={factors.noiseLevel} details={factorDetails.noiseLevel} />
</div>
{/* Proximity Example */}
<div className="space-y-2">
<h4 className="font-medium">Proximity to Amenities</h4>
<div className="grid gap-3 md:grid-cols-2">
<ProximityItem icon={Car} text="Public Transport: 300m" />
<ProximityItem icon={School} text="Schools: 1.2km" />
<ProximityItem icon={ShoppingBag} text="Shopping: 500m" />
<ProximityItem icon={Building} text="Hospitals: 2.5km" />
</div>
</div>
</div>
</CardContent>
<StepFooter onPrev={() => setActiveStep(3)} onNext={() => setActiveStep(5)} />
</Card>
);
}
// Step 5 Component
function Step5Content({
predictedPrice,
confidence,
priceRange,
setActiveStep,
}: {
predictedPrice: number;
confidence: number;
priceRange: { lower: number; upper: number };
setActiveStep: (step: number) => void;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Final Prediction</CardTitle>
<CardDescription>The AI-predicted price based on all analyzed factors</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Price Box */}
<div className="rounded-lg bg-muted p-6 text-center">
<h3 className="text-lg font-medium text-muted-foreground">Predicted Price</h3>
<div className="mt-2 text-4xl font-bold">{formatCurrency(predictedPrice)}</div>
<div className="mt-2 text-sm text-muted-foreground">Confidence Level: {(confidence * 100).toFixed(0)}%</div>
</div>
{/* Price Range Box */}
<div className="rounded-lg border p-4">
<h4 className="font-medium flex items-center gap-2">
<Info className="h-4 w-4 text-primary" /> Price Range
</h4>
<p className="mt-2 text-sm text-muted-foreground">
Based on our model's confidence, the likely market range is:
</p>
<div className="mt-3 flex justify-between text-sm">
<div>
<div className="font-medium">Lower Bound</div>
<div className="text-muted-foreground">{formatCurrency(priceRange.lower)}</div>
</div>
<div className="text-center">
<div className="font-medium">Prediction</div>
<div className="text-primary font-bold">{formatCurrency(predictedPrice)}</div>
</div>
<div className="text-right">
<div className="font-medium">Upper Bound</div>
<div className="text-muted-foreground">{formatCurrency(priceRange.upper)}</div>
</div>
</div>
</div>
{/* Summary */}
<div className="space-y-2">
<h4 className="font-medium">Summary of Factors</h4>
<p className="text-sm text-muted-foreground">This prediction considers:</p>
<ul className="mt-2 space-y-1 text-sm list-disc list-inside">
<li>Property characteristics (size, age, layout)</li>
<li>Location and neighborhood profile</li>
<li>Recent market trends and comparable sales</li>
<li>Environmental factors and amenity access</li>
</ul>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => setActiveStep(4)}>
Previous
</Button>
<Link href="/map">
<Button variant="default">Back to Map</Button>
</Link>
</CardFooter>
</Card>
);
}
// --- Sub-components for Steps ---
function InfoCard({ icon: Icon, title, description }: { icon: React.ElementType; title: string; description: string }) {
return (
<div className="flex items-start gap-3 rounded-lg border p-3">
<Icon className="mt-0.5 h-5 w-5 text-primary flex-shrink-0" />
<div>
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
function FactorCard({
title,
factor,
details,
}: {
title: string;
factor: string;
details: { icon: React.ElementType; color: string; text: string };
}) {
const Icon = details.icon;
const colorClass = `bg-${details.color}-500`; // Requires Tailwind JIT or safelisting
const textColorClass = `text-${details.color}-500`;
return (
<div className="flex flex-col items-center rounded-lg border p-4 text-center">
<Icon className={`h-8 w-8 mb-2 ${textColorClass}`} />
<h4 className="font-medium text-sm">{title}</h4>
<div className={`mt-2 flex items-center gap-2`}>
{/* Explicit colors might be safer than dynamic Tailwind classes */}
<div
className={`h-3 w-3 rounded-full`}
style={{
backgroundColor: details.color === "green" ? "#22c55e" : details.color === "yellow" ? "#eab308" : "#ef4444",
}}></div>
<span className="text-sm capitalize">{factor}</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">{details.text}</p>
</div>
);
}
function ProximityItem({ icon: Icon, text }: { icon: React.ElementType; text: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border p-2 text-xs">
<Icon className="h-4 w-4 text-primary flex-shrink-0" />
<div>{text}</div>
</div>
);
}
function StepFooter({ onPrev, onNext }: { onPrev?: () => void; onNext?: () => void }) {
return (
<CardFooter className="flex justify-between">
{onPrev ? (
<Button variant="outline" onClick={onPrev}>
Previous
</Button>
) : (
<div></div>
)}
{onNext ? (
<Button onClick={onNext}>
Next Step <ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<div></div>
)}
</CardFooter>
);
}

View File

@ -0,0 +1,21 @@
/*
========================================
File: frontend/app/(routes)/map/layout.tsx
========================================
*/
import type React from "react";
// import { PageLayout } from "@/components/common/PageLayout"; // Example using a common layout
// This layout is specific to the map feature's route group
export default function MapFeatureLayout({ children }: { children: React.ReactNode }) {
return (
// <PageLayout className="flex flex-row"> {/* Example using common layout */}
// The MapSidebar might be rendered here if it's part of the layout
<div className="relative flex-1 h-full w-full">
{" "}
{/* Ensure content takes up space */}
{children}
</div>
// </PageLayout>
);
}

View File

@ -0,0 +1,73 @@
/*
========================================
File: frontend/app/(routes)/map/page.tsx
========================================
*/
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
// Import common components
import { Button } from "@/components/ui/button";
// NOTE: ThemeProvider and ThemeController are in the root layout or a higher common layout now
// Import feature-specific components/contexts/types
import { MapContainer } from "@/features/map/components/map-container";
// MapSidebar might be part of the layout now, if shared, otherwise import here
// import { MapSidebar } from "@/features/map/components/map-sidebar";
import { MapHeader } from "@/features/map/components/map-header"; // Map specific header
import { OverlayProvider } from "@/features/map/components/overlay-system/overlay-context";
import { OverlayDock } from "@/features/map/components/overlay-system/overlay-dock";
import { AnalyticsOverlay } from "@/features/map/components/analytics-overlay";
import { FiltersOverlay } from "@/features/map/components/filters-overlay";
import { ChatOverlay } from "@/features/map/components/chat-overlay";
import type { MapLocation } from "@/features/map/types";
export default function MapPage() {
const [selectedLocation, setSelectedLocation] = useState<MapLocation>({
lat: 13.7563,
lng: 100.5018,
name: "Bangkok",
});
// Main page structure remains similar, but imports are updated
return (
// ThemeProvider/Controller likely moved to root layout
// SidebarProvider might be moved too, depending on its scope
// Assuming OverlayProvider is specific to this map page context
<OverlayProvider>
{/* The outer div with flex, h-screen etc. should be handled by the layout file or a common PageLayout */}
<div className="flex h-full w-full flex-col overflow-hidden">
{" "}
{/* Simplified for page content */}
<MapHeader />
<div className="relative flex-1 overflow-hidden">
<MapContainer selectedLocation={selectedLocation} />
{/* Prediction model banner */}
<div className="absolute left-1/2 top-4 -translate-x-1/2 z-10">
<div className="flex items-center gap-2 rounded-lg bg-card/95 backdrop-blur-sm border border-border/50 px-4 py-2 shadow-lg">
<div>
<h3 className="font-medium">Price Prediction: 15,000,000 ฿</h3>
<p className="text-xs text-muted-foreground">Based on our AI model analysis</p>
</div>
<Link href="/model-explanation">
<Button size="sm" variant="outline" className="gap-1">
Explain <ArrowRight className="h-3 w-3" />
</Button>
</Link>
</div>
</div>
{/* Overlay System */}
<AnalyticsOverlay />
<FiltersOverlay />
<ChatOverlay />
<OverlayDock position="bottom" />
</div>
</div>
</OverlayProvider>
);
}

View File

@ -1,31 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss" prefix(tw);
@plugin "tailwindcss-animate";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body {
font-family: Arial, Helvetica, sans-serif;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--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: 221.2 83.2% 53.3%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
/* Sidebar specific colors */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
@ -34,38 +134,36 @@
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Overlay constraints */
--max-overlay-width: calc(100vw - 32px);
--max-overlay-height: calc(100vh - 32px);
}
.dark {
--background: 240 10% 3.9%;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
/* Sidebar specific colors for dark mode */
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 0 0% 98%;
--sidebar-primary-foreground: 240 5.9% 10%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
@ -81,25 +179,3 @@
@apply bg-background text-foreground;
}
}
@layer components {
.overlay-container {
@apply absolute z-20;
max-width: var(--max-overlay-width);
max-height: var(--max-overlay-height);
}
.overlay-container[data-minimized="true"] {
@apply z-10;
}
.overlay-card {
@apply bg-card/95 backdrop-blur-sm border border-border/50 shadow-lg;
max-width: 100%;
max-height: 100%;
}
.overlay-minimized {
@apply w-[200px] h-auto;
}
}

View File

@ -1,20 +1,46 @@
import type { Metadata } from 'next'
import './globals.css'
/*
========================================
File: frontend/app/layout.tsx
========================================
*/
import type { Metadata } from "next";
import { Inter } from "next/font/google"; // Example using Inter font
import "./globals.css";
import { ThemeProvider } from "@/components/common/ThemeProvider"; // Correct path
import { ThemeController } from "@/components/common/ThemeController"; // Correct path
import { Toaster as SonnerToaster } from "@/components/ui/sonner"; // Sonner for notifications
import { Toaster as RadixToaster } from "@/components/ui/toaster"; // Shadcn Toaster (if using useToast hook)
// Setup font
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); // Define CSS variable
export const metadata: Metadata = {
title: 'v0 App',
description: 'Created with v0',
generator: 'v0.dev',
}
title: "Borbann - Data Platform", // More specific title
description: "Data integration, analysis, and visualization platform.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
{" "}
{/* suppressHydrationWarning needed for next-themes */}
<body className={`${inter.variable} font-sans`}>
{" "}
{/* Apply font class */}
{/* ThemeProvider should wrap everything for theme context */}
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{/* ThemeController can wrap specific parts or the whole app */}
{/* If placed here, it controls the base theme and color scheme */}
<ThemeController defaultColorScheme="Blue">{children}</ThemeController>
{/* Include Toaster components for notifications */}
<SonnerToaster />
<RadixToaster /> {/* Include if using the useToast hook */}
</ThemeProvider>
</body>
</html>
)
);
}

View File

@ -1,143 +0,0 @@
"use client"
import { Bot, LineChart, Wind, Droplets, Sparkles, Maximize2, Minimize2, ArrowLeftRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AreaChart } from "./area-chart"
import { Button } from "@/components/ui/button"
import { useOverlayContext } from "./overlay-context"
import { ScrollArea } from "@/components/ui/scroll-area"
interface AnalyticsPanelProps {
onChatClick: () => void
}
export function AnalyticsPanel({ onChatClick }: AnalyticsPanelProps) {
const { overlays, minimizeOverlay, maximizeOverlay, changePosition } = useOverlayContext()
const isMinimized = overlays.analytics.minimized
const position = overlays.analytics.position
if (isMinimized) {
return (
<Card className="overlay-card overlay-minimized">
<CardHeader className="p-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium">Analytics</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("analytics")}>
<Maximize2 className="h-4 w-4" />
</Button>
</CardHeader>
</Card>
)
}
return (
<Card className="overlay-card w-[350px] max-h-[80vh] overflow-hidden">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Analytics
</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={() => changePosition("analytics", position === "right" ? "left" : "right")}
title={`Move to ${position === "right" ? "left" : "right"}`}
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("analytics")}>
<Minimize2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<ScrollArea className="h-full max-h-[calc(min(80vh,var(--max-overlay-height))-60px)]">
<CardContent className="p-4">
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">Information in radius will be analyzed</p>
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<LineChart className="h-4 w-4 text-primary" />
Area Price History
</CardTitle>
<div className="flex items-baseline justify-between">
<div className="text-2xl font-bold">10,000,000 Baht</div>
</div>
<p className="text-xs text-muted-foreground">Overall Price History of this area</p>
</CardHeader>
<CardContent className="pt-0">
<AreaChart
data={[8500000, 9000000, 8800000, 9200000, 9500000, 9800000, 10000000]}
color="rgba(59, 130, 246, 0.5)"
/>
</CardContent>
</Card>
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<LineChart className="h-4 w-4 text-primary" />
Price Prediction
</CardTitle>
<div className="flex items-baseline justify-between">
<div className="text-2xl font-bold">15,000,000 Baht</div>
</div>
<p className="text-xs text-muted-foreground">The estimated price based on various factors.</p>
</CardHeader>
<CardContent className="pt-0">
<AreaChart
data={[10000000, 11000000, 12000000, 13000000, 14000000, 14500000, 15000000]}
color="rgba(16, 185, 129, 0.5)"
/>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Droplets className="h-4 w-4 text-blue-500" />
Flood Factor
</CardTitle>
<div className="flex items-center gap-2 mt-1">
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<span className="text-sm">Moderate</span>
</div>
</CardHeader>
</Card>
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Wind className="h-4 w-4 text-purple-500" />
Air Factor
</CardTitle>
<div className="flex items-center gap-2 mt-1">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<span className="text-sm">Bad</span>
</div>
</CardHeader>
</Card>
</div>
<Card
className="bg-card/50 border border-border/50 shadow-sm cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onChatClick}
>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Bot className="h-4 w-4 text-teal-500" />
Chat With AI
</CardTitle>
<p className="text-xs text-muted-foreground">Want to ask specific question?</p>
</CardHeader>
</Card>
</div>
</CardContent>
</ScrollArea>
</Card>
)
}

View File

@ -1,109 +0,0 @@
"use client"
import { useState } from "react"
import { Send, X, Minimize2, Maximize2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useOverlayContext } from "./overlay-context"
import { ScrollArea } from "@/components/ui/scroll-area"
interface ChatBotProps {
onClose: () => void
}
export function ChatBot({ onClose }: ChatBotProps) {
const { overlays, minimizeOverlay, maximizeOverlay } = useOverlayContext()
const isMinimized = overlays.chat.minimized
const [message, setMessage] = useState("")
const [chatHistory, setChatHistory] = useState([{ role: "bot", content: "Hi! How can I help you today?" }])
const handleSendMessage = () => {
if (!message.trim()) return
// Add user message to chat
setChatHistory([...chatHistory, { role: "user", content: message }])
// Simulate bot response (in a real app, this would call an API)
setTimeout(() => {
setChatHistory((prev) => [
...prev,
{
role: "bot",
content: "I can provide information about this area. What would you like to know?",
},
])
}, 1000)
setMessage("")
}
if (isMinimized) {
return (
<Card className="overlay-card overlay-minimized">
<CardHeader className="p-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium">ChatBot</CardTitle>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("chat")}>
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
)
}
return (
<Card className="overlay-card w-[400px] max-h-[80vh] overflow-hidden">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium">ChatBot</CardTitle>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("chat")}>
<Minimize2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0 flex flex-col h-[min(400px,calc(var(--max-overlay-height)-60px))]">
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{chatHistory.map((chat, index) => (
<div key={index} className={`flex ${chat.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
chat.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{chat.content}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="border-t p-4 flex items-center gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSendMessage()
}
}}
/>
<Button size="icon" onClick={handleSendMessage}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@ -1,78 +0,0 @@
"use client"
import { useState } from "react"
import { Send, MessageCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Overlay } from "./overlay-system/overlay"
export function ChatOverlay() {
const [message, setMessage] = useState("")
const [chatHistory, setChatHistory] = useState([{ role: "bot", content: "Hi! How can I help you today?" }])
const handleSendMessage = () => {
if (!message.trim()) return
// Add user message to chat
setChatHistory([...chatHistory, { role: "user", content: message }])
// Simulate bot response (in a real app, this would call an API)
setTimeout(() => {
setChatHistory((prev) => [
...prev,
{
role: "bot",
content: "I can provide information about this area. What would you like to know?",
},
])
}, 1000)
setMessage("")
}
return (
<Overlay
id="chat"
title="Chat Assistant"
icon={<MessageCircle className="h-5 w-5" />}
initialPosition="bottom-right"
initialIsOpen={false}
width="400px"
>
<div className="flex flex-col h-[400px]">
<div className="flex-1 p-4 overflow-auto">
<div className="space-y-4">
{chatHistory.map((chat, index) => (
<div key={index} className={`flex ${chat.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
chat.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{chat.content}
</div>
</div>
))}
</div>
</div>
<div className="border-t p-4 flex items-center gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSendMessage()
}
}}
/>
<Button size="icon" onClick={handleSendMessage}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</Overlay>
)
}

View File

@ -1,142 +0,0 @@
"use client"
import { useState } from "react"
import { Filter } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Overlay } from "./overlay-system/overlay"
export function FiltersOverlay() {
const [area, setArea] = useState("< 30 km")
const [timePeriod, setTimePeriod] = useState("All Time")
const [propertyType, setPropertyType] = useState("House")
const [priceRange, setPriceRange] = useState([5000000, 20000000])
const [activeTab, setActiveTab] = useState("basic")
return (
<Overlay
id="filters"
title="Property Filters"
icon={<Filter className="h-5 w-5" />}
initialPosition="bottom-left"
initialIsOpen={true}
width="350px"
>
<div className="h-[calc(min(70vh,500px))] overflow-auto p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-medium">Area Radius</label>
<Select value={area} onValueChange={setArea}>
<SelectTrigger>
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
<SelectItem value="< 10 km">{"< 10 km"}</SelectItem>
<SelectItem value="< 20 km">{"< 20 km"}</SelectItem>
<SelectItem value="< 30 km">{"< 30 km"}</SelectItem>
<SelectItem value="< 50 km">{"< 50 km"}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Time Period</label>
<Select value={timePeriod} onValueChange={setTimePeriod}>
<SelectTrigger>
<SelectValue placeholder="Select time period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Last Month">Last Month</SelectItem>
<SelectItem value="Last 3 Months">Last 3 Months</SelectItem>
<SelectItem value="Last Year">Last Year</SelectItem>
<SelectItem value="All Time">All Time</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Property Type</label>
<Select value={propertyType} onValueChange={setPropertyType}>
<SelectTrigger>
<SelectValue placeholder="Select property type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="House">House</SelectItem>
<SelectItem value="Condo">Condo</SelectItem>
<SelectItem value="Townhouse">Townhouse</SelectItem>
<SelectItem value="Land">Land</SelectItem>
<SelectItem value="Commercial">Commercial</SelectItem>
</SelectContent>
</Select>
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4">
<div className="space-y-3">
<div>
<div className="flex justify-between mb-1">
<label className="text-xs font-medium">Price Range</label>
<span className="text-xs text-muted-foreground">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(priceRange[0])}{" "}
-{" "}
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(priceRange[1])}
</span>
</div>
<Slider value={priceRange} min={1000000} max={50000000} step={1000000} onValueChange={setPriceRange} />
</div>
<div className="space-y-2">
<label className="text-xs font-medium">Environmental Factors</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="low-flood" className="text-xs">
Low Flood Risk
</Label>
<Switch id="low-flood" />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="good-air" className="text-xs">
Good Air Quality
</Label>
<Switch id="good-air" />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="low-noise" className="text-xs">
Low Noise Pollution
</Label>
<Switch id="low-noise" />
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<Button className="mt-4 w-full" size="sm">
Apply Filters
</Button>
</div>
</Overlay>
)
}

View File

@ -1,45 +0,0 @@
"use client"
import { useEffect, useRef } from "react"
interface MapContainerProps {
selectedLocation: {
lat: number
lng: number
name?: string
}
}
export function MapContainer({ selectedLocation }: MapContainerProps) {
const mapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// This is a placeholder for actual map integration
// In a real application, you would use a library like Google Maps, Mapbox, or Leaflet
const mapElement = mapRef.current
if (mapElement) {
// Simulate map loading with a background image
mapElement.style.backgroundImage = "url('/placeholder.svg?height=800&width=1200')"
mapElement.style.backgroundSize = "cover"
mapElement.style.backgroundPosition = "center"
}
// Clean up function
return () => {
if (mapElement) {
mapElement.style.backgroundImage = ""
}
}
}, [selectedLocation])
return (
<div ref={mapRef} className="absolute inset-0 h-full w-full bg-muted/20 dark:bg-muted/10">
{/* Map markers would be rendered here in a real implementation */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="h-6 w-6 animate-pulse rounded-full bg-red-500 ring-4 ring-red-300 dark:ring-red-800"></div>
</div>
</div>
)
}

View File

@ -1,127 +0,0 @@
"use client"
import type React from "react"
import {
Home,
Clock,
Map,
FileText,
Settings,
PenTool,
BarChart3,
Plane,
LineChart,
DollarSign,
MoreHorizontal,
} from "lucide-react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { usePathname } from "next/navigation"
export function MapSidebar() {
const pathname = usePathname()
const mainNavItems = [
{ name: "Home", icon: Home, href: "/" },
{ name: "My assets", icon: Clock, href: "/assets" },
{ name: "Models", icon: Map, href: "/models" },
{ name: "Trade", icon: LineChart, href: "/trade" },
{ name: "Earn", icon: DollarSign, href: "/earn" },
{ name: "Documentation", icon: FileText, href: "/documentation", badge: "NEW" },
{ name: "Pay", icon: Settings, href: "/pay" },
{ name: "More", icon: MoreHorizontal, href: "/more" },
]
const projectNavItems = [
{ name: "Design Engineering", icon: PenTool, href: "/projects/design" },
{ name: "Sales & Marketing", icon: BarChart3, href: "/projects/sales" },
{ name: "Travel", icon: Plane, href: "/projects/travel" },
]
return (
<aside className="flex h-full w-[240px] flex-col border-r bg-card dark:bg-card">
<div className="flex h-14 items-center border-b px-4">
<Link href="/" className="flex items-center gap-2 font-semibold">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
B
</div>
<span className="text-xl font-bold">BorBann</span>
</Link>
</div>
<div className="flex flex-col p-2">
<nav className="space-y-1">
{mainNavItems.map((item) => (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors",
pathname === item.href ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
)}
>
<div className="flex items-center gap-3">
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
</div>
{item.badge && (
<span className="rounded bg-primary/20 px-1.5 py-0.5 text-xs font-semibold text-primary">
{item.badge}
</span>
)}
</Link>
))}
</nav>
<div className="mt-6 rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/20 text-primary">
<Gift className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">Get ฿10</h3>
<p className="text-xs text-muted-foreground">Invite friends</p>
</div>
</div>
</div>
</div>
<div className="mt-auto border-t p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs">
GG
</div>
<div>
<div className="font-medium">GG_WPX</div>
<div className="text-xs text-muted-foreground">garfield.wpx@gmail.com</div>
</div>
</div>
</div>
</aside>
)
}
function Gift(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
)
}

View File

@ -1,94 +0,0 @@
"use client"
import { createContext, useContext, useState, type ReactNode } from "react"
type OverlayType = "analytics" | "filters" | "chat"
type OverlayPosition = "left" | "right"
interface OverlayState {
visible: boolean
minimized: boolean
position: OverlayPosition
}
interface OverlayContextType {
overlays: Record<OverlayType, OverlayState>
toggleOverlay: (type: OverlayType) => void
minimizeOverlay: (type: OverlayType) => void
maximizeOverlay: (type: OverlayType) => void
changePosition: (type: OverlayType, position: OverlayPosition) => void
}
const OverlayContext = createContext<OverlayContextType | undefined>(undefined)
export function OverlayProvider({ children }: { children: ReactNode }) {
const [overlays, setOverlays] = useState<Record<OverlayType, OverlayState>>({
analytics: { visible: true, minimized: false, position: "right" },
filters: { visible: true, minimized: false, position: "left" },
chat: { visible: false, minimized: false, position: "right" },
})
const toggleOverlay = (type: OverlayType) => {
setOverlays((prev) => ({
...prev,
[type]: {
...prev[type],
visible: !prev[type].visible,
minimized: false,
},
}))
}
const minimizeOverlay = (type: OverlayType) => {
setOverlays((prev) => ({
...prev,
[type]: {
...prev[type],
minimized: true,
},
}))
}
const maximizeOverlay = (type: OverlayType) => {
setOverlays((prev) => ({
...prev,
[type]: {
...prev[type],
minimized: false,
},
}))
}
const changePosition = (type: OverlayType, position: OverlayPosition) => {
setOverlays((prev) => ({
...prev,
[type]: {
...prev[type],
position,
},
}))
}
return (
<OverlayContext.Provider
value={{
overlays,
toggleOverlay,
minimizeOverlay,
maximizeOverlay,
changePosition,
}}
>
{children}
</OverlayContext.Provider>
)
}
export function useOverlayContext() {
const context = useContext(OverlayContext)
if (context === undefined) {
throw new Error("useOverlayContext must be used within an OverlayProvider")
}
return context
}

View File

@ -1,79 +0,0 @@
"use client"
import { MessageCircle, Filter, Layers, ArrowLeftRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useOverlayContext } from "./overlay-context"
export function OverlayControls() {
const { overlays, toggleOverlay, changePosition } = useOverlayContext()
return (
<TooltipProvider>
<div className="fixed bottom-4 right-4 flex flex-col gap-2 z-50">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => toggleOverlay("analytics")}
>
<Layers className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{overlays.analytics.visible ? "Hide analytics" : "Show analytics"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => toggleOverlay("filters")}
>
<Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">{overlays.filters.visible ? "Hide filters" : "Show filters"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => changePosition("analytics", overlays.analytics.position === "right" ? "left" : "right")}
>
<ArrowLeftRight className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Move analytics to the {overlays.analytics.position === "right" ? "left" : "right"}
</TooltipContent>
</Tooltip>
{!overlays.chat.visible && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => toggleOverlay("chat")}
>
<MessageCircle className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Open chat</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
)
}

View File

@ -1,59 +0,0 @@
"use client"
import { useOverlayContext } from "./overlay-context"
import { AnalyticsPanel } from "./analytics-panel"
import { PropertyFilters } from "./property-filters"
import { ChatBot } from "./chat-bot"
import { OverlayControls } from "./overlay-controls"
export function OverlayManager() {
const { overlays, toggleOverlay } = useOverlayContext()
// Function to ensure overlays stay within viewport bounds
const getPositionClasses = (position: string, type: string) => {
if (position === "right") {
return "right-4"
} else if (position === "left") {
return "left-4"
}
return type === "analytics" ? "right-4" : "left-4"
}
return (
<>
{/* Analytics Panel */}
{overlays.analytics.visible && (
<div
className={`overlay-container ${getPositionClasses(overlays.analytics.position, "analytics")} top-20`}
data-minimized={overlays.analytics.minimized}
>
<AnalyticsPanel onChatClick={() => toggleOverlay("chat")} />
</div>
)}
{/* Property Filters */}
{overlays.filters.visible && (
<div
className={`overlay-container ${getPositionClasses(overlays.filters.position, "filters")} bottom-4`}
data-minimized={overlays.filters.minimized}
>
<PropertyFilters />
</div>
)}
{/* Chat Bot */}
{overlays.chat.visible && (
<div
className={`overlay-container ${getPositionClasses(overlays.chat.position, "chat")} bottom-4`}
data-minimized={overlays.chat.minimized}
>
<ChatBot onClose={() => toggleOverlay("chat")} />
</div>
)}
{/* Overlay Controls */}
<OverlayControls />
</>
)
}

View File

@ -1,50 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useOverlay } from "./overlay-context"
interface OverlayDockProps {
position?: "bottom" | "right"
className?: string
}
export function OverlayDock({ position = "bottom", className }: OverlayDockProps) {
const { overlays, toggleOverlay } = useOverlay()
// Filter overlays that have icons
const overlaysWithIcons = Object.values(overlays).filter((overlay) => overlay.icon)
if (overlaysWithIcons.length === 0) return null
const positionClasses = {
bottom: "fixed bottom-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
right: "fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
}
return (
<TooltipProvider>
<div className={positionClasses[position]}>
{overlaysWithIcons.map((overlay) => (
<div key={overlay.id}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={overlay.isOpen ? "default" : "outline"}
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => toggleOverlay(overlay.id)}
>
{overlay.icon}
</Button>
</TooltipTrigger>
<TooltipContent side={position === "bottom" ? "top" : "left"}>
{overlay.isOpen ? `Hide ${overlay.title}` : `Show ${overlay.title}`}
</TooltipContent>
</Tooltip>
</div>
))}
</div>
</TooltipProvider>
)
}

View File

@ -1,156 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useState, useRef } from "react"
import { X, Minimize2, Maximize2, Move } from "lucide-react"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useOverlay, type OverlayId, type OverlayPosition } from "./overlay-context"
interface OverlayProps {
id: OverlayId
title: string
icon?: React.ReactNode
initialPosition?: OverlayPosition
initialIsOpen?: boolean
className?: string
children: React.ReactNode
onClose?: () => void
showMinimize?: boolean
width?: string
height?: string
maxHeight?: string
}
export function Overlay({
id,
title,
icon,
initialPosition = "bottom-right",
initialIsOpen = false,
className,
children,
onClose,
showMinimize = true,
width = "350px",
height = "auto",
maxHeight = "80vh",
}: OverlayProps) {
const { overlays, registerOverlay, unregisterOverlay, closeOverlay, minimizeOverlay, maximizeOverlay, bringToFront } =
useOverlay()
const [isDragging, setIsDragging] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null)
// Register overlay on mount
useEffect(() => {
registerOverlay(id, {
title,
icon,
position: initialPosition,
isOpen: initialIsOpen,
})
// Unregister on unmount
return () => unregisterOverlay(id)
}, [id])
// Get overlay state
const overlay = overlays[id]
if (!overlay) return null
const handleClose = () => {
closeOverlay(id)
if (onClose) onClose()
}
const handleMinimize = () => {
minimizeOverlay(id)
}
const handleMaximize = () => {
maximizeOverlay(id)
}
const handleHeaderClick = () => {
if (!isDragging) {
bringToFront(id)
}
}
// Position classes based on position
const positionClasses = {
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
}
// If not open, don't render
if (!overlay.isOpen) return null
// Render minimized state
if (overlay.isMinimized) {
return (
<div className={cn("fixed z-10", positionClasses[overlay.position])} style={{ zIndex: overlay.zIndex }}>
<Card className="w-[200px] shadow-lg bg-card/95 backdrop-blur-sm border border-border/50">
<CardHeader className="p-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{icon && <span className="text-primary">{icon}</span>}
{title}
</CardTitle>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleMaximize}>
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
</div>
)
}
// Render full overlay
return (
<div
ref={overlayRef}
className={cn("fixed z-10", positionClasses[overlay.position])}
style={{ zIndex: overlay.zIndex }}
>
<Card
className={cn("shadow-lg bg-card/95 backdrop-blur-sm border border-border/50 overflow-hidden", className)}
style={{
width,
height,
maxHeight,
maxWidth: "calc(100vw - 32px)",
}}
>
<CardHeader className="pb-2 flex flex-row items-center justify-between cursor-move" onClick={handleHeaderClick}>
<CardTitle className="text-sm font-medium flex items-center gap-2">
{icon && <span className="text-primary">{icon}</span>}
{title}
<Move className="h-3 w-3 text-muted-foreground ml-1" />
</CardTitle>
<div className="flex items-center gap-1">
{showMinimize && (
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleMinimize}>
<Minimize2 className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">{children}</CardContent>
</Card>
</div>
)
}

View File

@ -1,10 +0,0 @@
import type React from "react"
export default function MapLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative">
{children}
</div>
)
}

View File

@ -1,71 +0,0 @@
"use client"
import { useState } from "react"
import { MapContainer } from "./components/map-container"
import { MapSidebar } from "./components/map-sidebar"
import { MapHeader } from "./components/map-header"
import { SidebarProvider } from "@/components/ui/sidebar"
import { ThemeProvider } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react"
import Link from "next/link"
import { OverlayProvider } from "./components/overlay-system/overlay-context"
import { OverlayDock } from "./components/overlay-system/overlay-dock"
import { AnalyticsOverlay } from "./components/analytics-overlay"
import { FiltersOverlay } from "./components/filters-overlay"
import { ChatOverlay } from "./components/chat-overlay"
import { ThemeController } from "@/components/theme-controller"
export default function MapPage() {
const [selectedLocation, setSelectedLocation] = useState<{
lat: number
lng: number
name?: string
}>({
lat: 13.7563,
lng: 100.5018,
name: "Bangkok",
})
return (
<ThemeProvider defaultTheme="dark">
<SidebarProvider>
<OverlayProvider>
<ThemeController defaultColorScheme="Blue">
<div className="flex h-screen w-full overflow-hidden bg-background">
<MapSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<MapHeader />
<div className="relative flex-1 overflow-hidden">
<MapContainer selectedLocation={selectedLocation} />
{/* Prediction model banner */}
<div className="absolute left-1/2 top-4 -translate-x-1/2 z-10">
<div className="flex items-center gap-2 rounded-lg bg-card/95 backdrop-blur-sm border border-border/50 px-4 py-2 shadow-lg">
<div>
<h3 className="font-medium">Price Prediction: 15,000,000 ฿</h3>
<p className="text-xs text-muted-foreground">Based on our AI model analysis</p>
</div>
<Link href="/model-explanation">
<Button size="sm" variant="outline" className="gap-1">
Explain <ArrowRight className="h-3 w-3" />
</Button>
</Link>
</div>
</div>
{/* Overlay System */}
<AnalyticsOverlay />
<FiltersOverlay />
<ChatOverlay />
<OverlayDock position="bottom" />
</div>
</div>
</div>
</ThemeController>
</OverlayProvider>
</SidebarProvider>
</ThemeProvider>
)
}

View File

@ -1,65 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "@/components/ui/chart"
interface Feature {
name: string
importance: number
value: string
impact: "positive" | "negative" | "neutral"
}
interface FeatureImportanceChartProps {
features: Feature[]
}
export function FeatureImportanceChart({ features }: FeatureImportanceChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
// Sort features by importance
const sortedFeatures = [...features].sort((a, b) => b.importance - a.importance)
const getBarColor = (impact: string) => {
if (impact === "positive") return "#10b981"
if (impact === "negative") return "#ef4444"
return "#f59e0b"
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sortedFeatures} layout="vertical" margin={{ top: 20, right: 30, left: 100, bottom: 5 }}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
stroke={isDark ? "#374151" : "#e5e7eb"}
/>
<XAxis
type="number"
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
stroke={isDark ? "#9ca3af" : "#6b7280"}
/>
<YAxis dataKey="name" type="category" width={90} stroke={isDark ? "#9ca3af" : "#6b7280"} />
<Tooltip
formatter={(value: number) => [`${value}%`, "Importance"]}
contentStyle={{
backgroundColor: isDark ? "#1f2937" : "white",
borderRadius: "0.375rem",
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
fontSize: "0.75rem",
color: isDark ? "#e5e7eb" : "#1f2937",
}}
/>
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
{sortedFeatures.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.impact)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}

View File

@ -1,75 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
Cell,
} from "@/components/ui/chart"
interface PropertyData {
name: string
price: number
size: number
age: number
}
interface PriceComparisonChartProps {
property: PropertyData
comparisons: PropertyData[]
}
export function PriceComparisonChart({ property, comparisons }: PriceComparisonChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
// Combine property and comparisons
const data = [property, ...comparisons]
// Format the price for display
const formatPrice = (value: number) => {
return new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? "#374151" : "#e5e7eb"} />
<XAxis dataKey="name" stroke={isDark ? "#9ca3af" : "#6b7280"} />
<YAxis tickFormatter={(value) => `${(value / 1000000).toFixed(1)}M`} stroke={isDark ? "#9ca3af" : "#6b7280"} />
<Tooltip
formatter={(value: number) => [formatPrice(value), "Price"]}
contentStyle={{
backgroundColor: isDark ? "#1f2937" : "white",
borderRadius: "0.375rem",
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
fontSize: "0.75rem",
color: isDark ? "#e5e7eb" : "#1f2937",
}}
/>
<Legend />
<Bar dataKey="price" name="Price" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={index === 0 ? "#3b82f6" : "#6b7280"}
fillOpacity={index === 0 ? 1 : 0.7}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}

View File

@ -1,640 +0,0 @@
"use client"
import { useState } from "react"
import {
ChevronRight,
Info,
ArrowRight,
Home,
Building,
Ruler,
Calendar,
Coins,
Droplets,
Wind,
Sun,
Car,
School,
ShoppingBag,
} from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { Slider } from "@/components/ui/slider"
import { FeatureImportanceChart } from "./components/feature-importance-chart"
import { PriceComparisonChart } from "./components/price-comparison-chart"
import { MapSidebar } from "../map/components/map-sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { ThemeProvider } from "@/components/theme-provider"
export default function ModelExplanationPage() {
const [activeStep, setActiveStep] = useState(1)
const [propertySize, setPropertySize] = useState(150)
const [propertyAge, setPropertyAge] = useState(5)
// Sample data for the model explanation
const propertyDetails = {
address: "123 Sukhumvit Road, Bangkok",
type: "Condominium",
size: 150, // sqm
bedrooms: 3,
bathrooms: 2,
age: 5, // years
floor: 15,
amenities: ["Swimming Pool", "Gym", "Security", "Parking"],
predictedPrice: 15000000, // THB
similarProperties: [
{ address: "125 Sukhumvit Road", price: 14500000, size: 145, age: 6 },
{ address: "130 Sukhumvit Road", price: 16200000, size: 160, age: 3 },
{ address: "118 Sukhumvit Road", price: 13800000, size: 140, age: 7 },
],
features: [
{ name: "Location", importance: 35, value: "Prime Area", impact: "positive" },
{ name: "Size", importance: 25, value: "150 sqm", impact: "positive" },
{ name: "Age", importance: 15, value: "5 years", impact: "neutral" },
{ name: "Amenities", importance: 10, value: "4 amenities", impact: "positive" },
{ name: "Floor", importance: 8, value: "15th floor", impact: "positive" },
{ name: "Environmental Factors", importance: 7, value: "Low flood risk", impact: "positive" },
],
}
const steps = [
{
id: 1,
title: "Property Details",
description: "Basic information about the property",
icon: Home,
},
{
id: 2,
title: "Feature Analysis",
description: "How each feature affects the price",
icon: Ruler,
},
{
id: 3,
title: "Market Comparison",
description: "Comparison with similar properties",
icon: Building,
},
{
id: 4,
title: "Environmental Factors",
description: "Impact of environmental conditions",
icon: Droplets,
},
{
id: 5,
title: "Final Prediction",
description: "The predicted price and confidence level",
icon: Coins,
},
]
// Calculate a new price based on slider changes
const calculateAdjustedPrice = () => {
// Simple formula for demonstration
const sizeImpact = (propertySize - 150) * 50000 // 50,000 THB per sqm
const ageImpact = (5 - propertyAge) * 200000 // 200,000 THB per year newer
return propertyDetails.predictedPrice + sizeImpact + ageImpact
}
const adjustedPrice = calculateAdjustedPrice()
return (
<ThemeProvider>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background">
<MapSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<header className="flex h-14 items-center justify-between border-b px-4 bg-background">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/map" className="hover:text-foreground">
Map
</Link>
<ChevronRight className="h-4 w-4" />
<span className="font-medium text-foreground">Price Prediction Model</span>
</div>
</header>
{/* Main content */}
<div className="flex flex-1 overflow-auto p-6">
<div className="mx-auto w-full max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Explainable Price Prediction Model</h1>
<p className="mt-2 text-muted-foreground">
Understand how our AI model predicts property prices and what factors influence the valuation.
</p>
</div>
{/* Steps navigation */}
<div className="mb-8">
<div className="flex flex-wrap gap-4">
{steps.map((step) => (
<Button
key={step.id}
variant={activeStep === step.id ? "default" : "outline"}
className="flex items-center gap-2"
onClick={() => setActiveStep(step.id)}
>
<step.icon className="h-4 w-4" />
<span>{step.title}</span>
</Button>
))}
</div>
<div className="mt-4">
<Progress value={(activeStep / steps.length) * 100} className="h-2" />
</div>
</div>
{/* Step content */}
<div className="grid gap-6 md:grid-cols-3">
{/* Left column - Property details */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Property Details</CardTitle>
<CardDescription>{propertyDetails.address}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Type</span>
<span className="font-medium">{propertyDetails.type}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Size</span>
<span className="font-medium">{propertySize} sqm</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Bedrooms</span>
<span className="font-medium">{propertyDetails.bedrooms}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Bathrooms</span>
<span className="font-medium">{propertyDetails.bathrooms}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Age</span>
<span className="font-medium">{propertyAge} years</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Floor</span>
<span className="font-medium">{propertyDetails.floor}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Adjust Parameters</CardTitle>
<CardDescription>See how changes affect the prediction</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between">
<label className="text-sm font-medium">Property Size</label>
<span className="text-sm text-muted-foreground">{propertySize} sqm</span>
</div>
<Slider
value={[propertySize]}
min={50}
max={300}
step={5}
onValueChange={(value) => setPropertySize(value[0])}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<label className="text-sm font-medium">Property Age</label>
<span className="text-sm text-muted-foreground">{propertyAge} years</span>
</div>
<Slider
value={[propertyAge]}
min={0}
max={20}
step={1}
onValueChange={(value) => setPropertyAge(value[0])}
/>
</div>
</CardContent>
<CardFooter>
<div className="w-full">
<div className="flex justify-between items-baseline">
<span className="text-sm text-muted-foreground">Adjusted Price</span>
<span className="text-xl font-bold">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(adjustedPrice)}
</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{adjustedPrice > propertyDetails.predictedPrice ? "↑" : "↓"}
{Math.abs(adjustedPrice - propertyDetails.predictedPrice).toLocaleString()} THB from
original prediction
</div>
</div>
</CardFooter>
</Card>
</div>
{/* Middle column - Step content */}
<div className="md:col-span-2 space-y-6">
{activeStep === 1 && (
<Card>
<CardHeader>
<CardTitle>Property Overview</CardTitle>
<CardDescription>Basic information used in our prediction model</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p>
Our AI model begins by analyzing the core attributes of your property. These fundamental
characteristics form the baseline for our prediction.
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-start gap-2 rounded-lg border p-3">
<Home className="mt-0.5 h-5 w-5 text-primary" />
<div>
<h4 className="font-medium">Property Type</h4>
<p className="text-sm text-muted-foreground">
{propertyDetails.type} properties in this area have specific market dynamics
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border p-3">
<Ruler className="mt-0.5 h-5 w-5 text-primary" />
<div>
<h4 className="font-medium">Size & Layout</h4>
<p className="text-sm text-muted-foreground">
{propertyDetails.size} sqm with {propertyDetails.bedrooms} bedrooms and{" "}
{propertyDetails.bathrooms} bathrooms
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border p-3">
<Calendar className="mt-0.5 h-5 w-5 text-primary" />
<div>
<h4 className="font-medium">Property Age</h4>
<p className="text-sm text-muted-foreground">
Built {propertyDetails.age} years ago, affecting depreciation calculations
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border p-3">
<Building className="mt-0.5 h-5 w-5 text-primary" />
<div>
<h4 className="font-medium">Floor & View</h4>
<p className="text-sm text-muted-foreground">
Located on floor {propertyDetails.floor}, impacting value and desirability
</p>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={() => setActiveStep(2)} className="ml-auto">
Next Step <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
)}
{activeStep === 2 && (
<Card>
<CardHeader>
<CardTitle>Feature Analysis</CardTitle>
<CardDescription>How different features impact the predicted price</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
Our model analyzes various features of your property and determines how each one
contributes to the final price prediction. Below is a breakdown of the most important
factors.
</p>
<div className="h-[300px]">
<FeatureImportanceChart features={propertyDetails.features} />
</div>
<div className="space-y-4">
{propertyDetails.features.map((feature) => (
<div key={feature.name} className="space-y-1">
<div className="flex justify-between">
<span className="text-sm font-medium">{feature.name}</span>
<span
className={`text-sm ${
feature.impact === "positive"
? "text-green-500"
: feature.impact === "negative"
? "text-red-500"
: "text-yellow-500"
}`}
>
{feature.impact === "positive"
? "↑ Positive"
: feature.impact === "negative"
? "↓ Negative"
: "→ Neutral"}{" "}
Impact
</span>
</div>
<div className="flex items-center gap-2">
<Progress value={feature.importance} className="h-2" />
<span className="text-sm text-muted-foreground">{feature.importance}%</span>
</div>
<p className="text-xs text-muted-foreground">{feature.value}</p>
</div>
))}
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => setActiveStep(1)}>
Previous
</Button>
<Button onClick={() => setActiveStep(3)}>
Next Step <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
)}
{activeStep === 3 && (
<Card>
<CardHeader>
<CardTitle>Market Comparison</CardTitle>
<CardDescription>
How your property compares to similar properties in the area
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
Our model analyzes recent sales data from similar properties in your area to establish a
baseline for comparison. This helps ensure our prediction is aligned with current market
conditions.
</p>
<div className="h-[300px]">
<PriceComparisonChart
property={{
name: "Your Property",
price: propertyDetails.predictedPrice,
size: propertyDetails.size,
age: propertyDetails.age,
}}
comparisons={propertyDetails.similarProperties.map((p) => ({
name: p.address.split(" ")[0],
price: p.price,
size: p.size,
age: p.age,
}))}
/>
</div>
<div className="space-y-4">
<h4 className="font-medium">Similar Properties</h4>
<div className="grid gap-4 md:grid-cols-3">
{propertyDetails.similarProperties.map((property, index) => (
<div key={index} className="rounded-lg border p-3">
<div className="font-medium">{property.address}</div>
<div className="mt-1 text-sm text-muted-foreground">
{property.size} sqm, {property.age} years old
</div>
<div className="mt-2 font-bold">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(property.price)}
</div>
</div>
))}
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => setActiveStep(2)}>
Previous
</Button>
<Button onClick={() => setActiveStep(4)}>
Next Step <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
)}
{activeStep === 4 && (
<Card>
<CardHeader>
<CardTitle>Environmental Factors</CardTitle>
<CardDescription>How environmental conditions affect the property value</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<p>
Environmental factors can significantly impact property values. Our model considers
various environmental conditions to provide a more accurate prediction.
</p>
<div className="grid gap-4 md:grid-cols-3">
<div className="flex flex-col items-center rounded-lg border p-4">
<Droplets className="h-8 w-8 text-blue-500 mb-2" />
<h4 className="font-medium">Flood Risk</h4>
<div className="mt-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<span>Moderate</span>
</div>
<p className="mt-2 text-xs text-center text-muted-foreground">
Historical data shows moderate flood risk in this area
</p>
</div>
<div className="flex flex-col items-center rounded-lg border p-4">
<Wind className="h-8 w-8 text-purple-500 mb-2" />
<h4 className="font-medium">Air Quality</h4>
<div className="mt-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<span>Poor</span>
</div>
<p className="mt-2 text-xs text-center text-muted-foreground">
Air quality is below average, affecting property value
</p>
</div>
<div className="flex flex-col items-center rounded-lg border p-4">
<Sun className="h-8 w-8 text-amber-500 mb-2" />
<h4 className="font-medium">Noise Level</h4>
<div className="mt-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500"></div>
<span>Low</span>
</div>
<p className="mt-2 text-xs text-center text-muted-foreground">
The area has relatively low noise pollution
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">Proximity to Amenities</h4>
<div className="grid gap-3 md:grid-cols-2">
<div className="flex items-center gap-2 rounded-lg border p-2">
<Car className="h-4 w-4 text-primary" />
<div className="text-sm">Public Transport: 300m</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-2">
<School className="h-4 w-4 text-primary" />
<div className="text-sm">Schools: 1.2km</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-2">
<ShoppingBag className="h-4 w-4 text-primary" />
<div className="text-sm">Shopping: 500m</div>
</div>
<div className="flex items-center gap-2 rounded-lg border p-2">
<Building className="h-4 w-4 text-primary" />
<div className="text-sm">Hospitals: 2.5km</div>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => setActiveStep(3)}>
Previous
</Button>
<Button onClick={() => setActiveStep(5)}>
Next Step <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
)}
{activeStep === 5 && (
<Card>
<CardHeader>
<CardTitle>Final Prediction</CardTitle>
<CardDescription>The predicted price and confidence level</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="rounded-lg bg-muted p-6 text-center">
<h3 className="text-lg font-medium text-muted-foreground">Predicted Price</h3>
<div className="mt-2 text-4xl font-bold">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(adjustedPrice)}
</div>
<div className="mt-2 text-sm text-muted-foreground">Confidence Level: 92%</div>
</div>
<div className="rounded-lg border p-4">
<h4 className="font-medium flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
Price Range
</h4>
<p className="mt-2 text-sm text-muted-foreground">
Based on our model's confidence level, the price could range between:
</p>
<div className="mt-3 flex justify-between text-sm">
<div>
<div className="font-medium">Lower Bound</div>
<div className="text-muted-foreground">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(adjustedPrice * 0.95)}
</div>
</div>
<div className="text-center">
<div className="font-medium">Prediction</div>
<div className="text-primary font-bold">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(adjustedPrice)}
</div>
</div>
<div className="text-right">
<div className="font-medium">Upper Bound</div>
<div className="text-muted-foreground">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(adjustedPrice * 1.05)}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">Summary of Factors</h4>
<p className="text-sm text-muted-foreground">
The final prediction is based on a combination of all factors analyzed in previous
steps:
</p>
<ul className="mt-2 space-y-1 text-sm">
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
<span>Property characteristics (size, age, layout)</span>
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
<span>Location and neighborhood analysis</span>
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
<span>Market trends and comparable properties</span>
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
<span>Environmental factors and amenities</span>
</li>
</ul>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => setActiveStep(4)}>
Previous
</Button>
<Button variant="default" onClick={() => (window.location.href = "/map")}>
Back to Map
</Button>
</CardFooter>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</SidebarProvider>
</ThemeProvider>
)
}

View File

@ -1,102 +1,35 @@
/*
========================================
File: frontend/app/page.tsx
========================================
*/
import Image from "next/image";
import Link from "next/link"; // Import Link
import { Button } from "@/components/ui/button"; // Import common UI
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex flex-col min-h-screen items-center justify-center p-8 sm:p-20 text-center">
{/* Replace with your actual logo/branding */}
<h1 className="text-4xl font-bold mb-4">Welcome to Borbann</h1>
<p className="text-lg text-muted-foreground mb-8">Your data integration and analysis platform.</p>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
<div className="flex flex-col sm:flex-row gap-4 items-center">
<Link href="/map">
<Button size="lg">Go to Map</Button>
</Link>
<Link href="/documentation">
{" "}
{/* Example link */}
<Button size="lg" variant="outline">
Read Docs
</Button>
</Link>
</div>
{/* Optional: Add more introductory content or links */}
<footer className="absolute bottom-8 text-sm text-muted-foreground">
© {new Date().getFullYear()} Borbann Project.
</footer>
</div>
);

View File

@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
@ -15,7 +15,10 @@
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"hooks": "@/hooks",
"features": "@/features",
"types": "@/types",
"services": "@/services"
},
"iconLibrary": "lucide"
}
}

View File

@ -0,0 +1,29 @@
/*
========================================
File: frontend/components/common/PageLayout.tsx (NEW - Example)
========================================
*/
import React, { ReactNode } from "react";
import { cn } from "@/lib/utils";
// Example: Assuming you might have a common Header/Footer/Sidebar structure
// import { AppHeader } from './AppHeader';
// import { AppFooter } from './AppFooter';
interface PageLayoutProps {
children: ReactNode;
className?: string;
}
/**
* DUMMY: A basic layout component for pages.
* Might include common headers, footers, or side navigation structures.
*/
export function PageLayout({ children, className }: PageLayoutProps) {
return (
<div className={cn("flex flex-col min-h-screen", className)}>
{/* <AppHeader /> */} {/* Example: Shared Header */}
<main className="flex-1">{children}</main>
{/* <AppFooter /> */} {/* Example: Shared Footer */}
</div>
);
}

View File

@ -1,9 +1,14 @@
"use client"
/*
========================================
File: frontend/components/common/ThemeController.tsx
========================================
*/
"use client";
import { useState, useEffect, useRef, type ReactNode } from "react"
import { useTheme } from "next-themes"
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useTheme } from "next-themes";
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@ -11,70 +16,65 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
// Define available color schemes
// Define available color schemes (these affect CSS variables)
const colorSchemes = [
{ name: "Blue", primary: "221.2 83.2% 53.3%" },
{ name: "Blue", primary: "221.2 83.2% 53.3%" }, // Default blue
{ name: "Green", primary: "142.1 76.2% 36.3%" },
{ name: "Purple", primary: "262.1 83.3% 57.8%" },
{ name: "Orange", primary: "24.6 95% 53.1%" },
{ name: "Teal", primary: "173 80.4% 40%" },
]
];
interface ThemeControllerProps {
children: ReactNode
defaultColorScheme?: string
children: ReactNode;
defaultColorScheme?: string;
}
export function ThemeController({ children, defaultColorScheme = "Blue" }: ThemeControllerProps) {
const { setTheme, theme } = useTheme()
const [colorScheme, setColorScheme] = useState(defaultColorScheme)
const [overlayBoundaries, setOverlayBoundaries] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const { setTheme, theme } = useTheme();
const [colorScheme, setColorScheme] = useState(defaultColorScheme);
// State for overlay boundaries removed, as overlay context now manages positioning/constraints.
// const [overlayBoundaries, setOverlayBoundaries] = useState({ width: 0, height: 0 });
const containerRef = useRef<HTMLDivElement>(null);
// Update overlay boundaries when window resizes
// Update overlay boundaries - This logic might be better placed within the OverlayProvider
// or removed if not strictly necessary for the controller's function.
// Kept here for now as per original code structure, but consider moving it.
// useEffect(() => {
// const updateBoundaries = () => {
// if (containerRef.current) {
// const width = containerRef.current.clientWidth;
// const height = containerRef.current.clientHeight;
// document.documentElement.style.setProperty("--max-overlay-width", `${width - 32}px`);
// document.documentElement.style.setProperty("--max-overlay-height", `${height - 32}px`);
// }
// };
// updateBoundaries();
// window.addEventListener("resize", updateBoundaries);
// return () => window.removeEventListener("resize", updateBoundaries);
// }, []);
// Apply color scheme by setting the '--primary' CSS variable
useEffect(() => {
const updateBoundaries = () => {
if (containerRef.current) {
setOverlayBoundaries({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
})
}
}
// Initial update
updateBoundaries()
// Add resize listener
window.addEventListener("resize", updateBoundaries)
// Cleanup
return () => window.removeEventListener("resize", updateBoundaries)
}, [])
// Apply color scheme
useEffect(() => {
const scheme = colorSchemes.find((s) => s.name === colorScheme)
const scheme = colorSchemes.find((s) => s.name === colorScheme);
if (scheme) {
document.documentElement.style.setProperty("--primary", scheme.primary)
document.documentElement.style.setProperty("--primary", scheme.primary);
// You might need to set --ring as well if it depends on primary
// document.documentElement.style.setProperty("--ring", scheme.primary);
}
}, [colorScheme])
// Apply CSS variables for overlay constraints
useEffect(() => {
document.documentElement.style.setProperty("--max-overlay-width", `${overlayBoundaries.width - 32}px`)
document.documentElement.style.setProperty("--max-overlay-height", `${overlayBoundaries.height - 32}px`)
}, [overlayBoundaries])
}, [colorScheme]);
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden" data-theme-controller="true">
{children}
{/* Theme Controller UI */}
<div className="fixed bottom-4 left-4 z-50 flex items-center gap-2">
<div className="fixed bottom-4 left-4 z-[999] flex items-center gap-2">
{" "}
{/* Ensure high z-index */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@ -83,8 +83,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
>
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md">
<Palette className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@ -123,8 +122,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
<DropdownMenuItem
key={scheme.name}
onClick={() => setColorScheme(scheme.name)}
className="flex items-center justify-between"
>
className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full" style={{ backgroundColor: `hsl(${scheme.primary})` }} />
<span>{scheme.name}</span>
@ -142,6 +140,5 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
</TooltipProvider>
</div>
</div>
)
);
}

View File

@ -0,0 +1,23 @@
/*
========================================
File: frontend/components/common/ThemeProvider.tsx
========================================
*/
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props} // Pass through any other props
>
{children}
</NextThemesProvider>
);
}

View File

@ -1,13 +1,24 @@
"use client"
/*
========================================
File: frontend/components/common/ThemeToggle.tsx
========================================
*/
"use client";
import { Moon, Sun, Laptop } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import React from "react";
import { Moon, Sun, Laptop } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
const { setTheme, theme } = useTheme();
return (
<TooltipProvider>
@ -42,6 +53,5 @@ export function ThemeToggle() {
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
);
}

View File

@ -1,12 +0,0 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange {...props}>
{children}
</NextThemesProvider>
)
}

View File

@ -1,16 +1,72 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import eslintJs from "@eslint/js"; // Import recommended rules
import tseslint from "typescript-eslint"; // Import TS plugin/parser
import eslintPluginReact from "eslint-plugin-react"; // Import React plugin
import eslintConfigNext from "eslint-config-next"; // Import Next.js specific config
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// FlatCompat for extending older eslintrc-style configs if needed (like next/core-web-vitals)
const compat = new FlatCompat({
baseDirectory: __dirname,
// resolvePluginsRelativeTo: __dirname, // might be needed depending on setup
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
// Base ESLint recommended rules
eslintJs.configs.recommended,
// TypeScript specific rules using the new flat config structure
...tseslint.configs.recommended, // Or recommendedTypeChecked if using type info
// React specific rules (new flat config structure)
{
plugins: {
react: eslintPluginReact,
},
rules: {
...eslintPluginReact.configs.recommended.rules,
...eslintPluginReact.configs["jsx-runtime"].rules, // Recommended for React 17+ JSX transform
"react/prop-types": "off", // Not needed with TypeScript
// Add other React specific rules here
},
settings: {
react: {
version: "detect", // Automatically detect React version
},
},
},
// Next.js specific rules using FlatCompat for `eslint-config-next`
// This often includes rules for react-hooks, jsx-a11y etc.
...compat.extends("next/core-web-vitals"),
// Custom rules or overrides
{
rules: {
// Example: Enforce no console logs in production (modify as needed)
// 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], // Warn on unused vars except those starting with _
"@typescript-eslint/no-explicit-any": "warn", // Warn against using 'any'
// Add more custom rules here
},
// Target specific files if necessary
// files: ["src/**/*.{ts,tsx}"],
},
// Ignore files if needed (e.g., generated files)
{
ignores: [
".next/",
"node_modules/",
"out/",
"build/",
// Add other ignored paths
],
},
];
export default eslintConfig;

View File

@ -0,0 +1,65 @@
/*
========================================
File: frontend/features/map/api/mapApi.ts
========================================
*/
import apiClient from "@/services/apiClient";
import type { APIResponse, PointOfInterest } from "@/types/api"; // Shared types
import type { MapBounds } from "../types"; // Feature-specific types
interface FetchPOIsParams {
bounds: MapBounds;
filters?: Record<string, any>;
}
/**
* DUMMY: Fetches Points of Interest based on map bounds and filters.
*/
export async function fetchPointsOfInterest(
params: FetchPOIsParams
): Promise<APIResponse<PointOfInterest[]>> {
console.log("DUMMY API: Fetching POIs with params:", params);
// Simulate building query parameters
const queryParams = new URLSearchParams({
north: params.bounds.north.toString(),
south: params.bounds.south.toString(),
east: params.bounds.east.toString(),
west: params.bounds.west.toString(),
});
if (params.filters) {
Object.entries(params.filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.set(key, String(value));
}
});
}
// Use the dummy apiClient
const response = await apiClient.get<PointOfInterest[]>(`/map/pois?${queryParams.toString()}`);
if (response.success) {
// Simulate adding more data if needed for testing
const dummyData: PointOfInterest[] = [
{ id: "poi1", lat: params.bounds.north - 0.01, lng: params.bounds.west + 0.01, name: "Dummy Cafe", type: "cafe" },
{ id: "poi2", lat: params.bounds.south + 0.01, lng: params.bounds.east - 0.01, name: "Dummy Park", type: "park" },
...(response.data || []) // Include data if apiClient simulation provides it
];
return { success: true, data: dummyData };
} else {
return response; // Forward the error response
}
}
// Add other map-related API functions here
export async function fetchMapAnalytics(bounds: MapBounds): Promise<APIResponse<any>> {
console.log("DUMMY API: Fetching Map Analytics with params:", bounds);
// Simulate building query parameters
const queryParams = new URLSearchParams({
north: bounds.north.toString(),
south: bounds.south.toString(),
east: bounds.east.toString(),
west: bounds.west.toString(),
});
return apiClient.get(`/map/analytics?${queryParams.toString()}`);
}

View File

@ -1,17 +1,22 @@
"use client"
/*
========================================
File: frontend/features/map/components/analytics-overlay.tsx
========================================
*/
"use client";
import { LineChart, Wind, Droplets, Sparkles, Bot } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AreaChart } from "./area-chart"
import { Overlay } from "./overlay-system/overlay"
import { useOverlay } from "./overlay-system/overlay-context"
import { LineChart, Wind, Droplets, Sparkles, Bot } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AreaChart } from "./area-chart";
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
import { useOverlay } from "./overlay-system/overlay-context";
export function AnalyticsOverlay() {
const { toggleOverlay } = useOverlay()
const { toggleOverlay } = useOverlay();
const handleChatClick = () => {
toggleOverlay("chat")
}
toggleOverlay("chat");
};
return (
<Overlay
@ -20,13 +25,13 @@ export function AnalyticsOverlay() {
icon={<Sparkles className="h-5 w-5" />}
initialPosition="top-right"
initialIsOpen={true}
width="350px"
>
width="350px">
<div className="h-[calc(min(70vh,600px))] overflow-auto">
<div className="p-4">
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">Information in radius will be analyzed</p>
{/* Area Price History Card */}
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
@ -46,6 +51,7 @@ export function AnalyticsOverlay() {
</CardContent>
</Card>
{/* Price Prediction Card */}
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
@ -65,6 +71,7 @@ export function AnalyticsOverlay() {
</CardContent>
</Card>
{/* Environmental Factors Cards */}
<div className="grid grid-cols-2 gap-4">
<Card className="bg-card/50 border border-border/50 shadow-sm">
<CardHeader className="p-4">
@ -93,10 +100,10 @@ export function AnalyticsOverlay() {
</Card>
</div>
{/* Chat With AI Card */}
<Card
className="bg-card/50 border border-border/50 shadow-sm cursor-pointer hover:bg-muted/50 transition-colors"
onClick={handleChatClick}
>
onClick={handleChatClick}>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Bot className="h-4 w-4 text-teal-500" />
@ -109,6 +116,5 @@ export function AnalyticsOverlay() {
</div>
</div>
</Overlay>
)
);
}

View File

@ -0,0 +1,10 @@
/*
========================================
File: frontend/features/map/components/analytics-panel.tsx
========================================
*/
// This component seems redundant with analytics-overlay.tsx in the new structure.
// If it has unique logic or display variation, keep it and refactor its imports.
// Otherwise, it can likely be removed, and its functionality merged into analytics-overlay.tsx.
// For this rewrite, assuming it's removed or its distinct logic is integrated elsewhere.
// If needed, its structure would be similar to analytics-overlay.tsx but maybe without the <Overlay> wrapper.

View File

@ -1,38 +1,45 @@
"use client"
/*
========================================
File: frontend/features/map/components/area-chart.tsx
========================================
*/
"use client";
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from "@/components/ui/chart"
import { useTheme } from "next-themes"
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from "@/components/ui/chart"; // Using shared ui chart components
import { useTheme } from "next-themes";
interface AreaChartProps {
data: number[]
color: string
data: number[];
color: string;
}
export function AreaChart({ data, color }: AreaChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
const { theme } = useTheme();
const isDark = theme === "dark";
// Generate labels (months)
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
// Generate labels (e.g., months or simple indices)
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]; // Example labels
// Format the data for the chart
const chartData = data.map((value, index) => ({
name: labels[index],
name: labels[index % labels.length] || `Point ${index + 1}`, // Use labels or fallback
value: value,
}))
}));
// Format the price for display
// Format the price for display in tooltip
const formatPrice = (value: number) => {
return new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
}).format(value);
};
return (
<div className="h-[80px] w-full">
{" "}
{/* Adjust height as needed */}
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<XAxis dataKey="name" hide />
@ -50,16 +57,17 @@ export function AreaChart({ data, color }: AreaChartProps) {
<Line
type="monotone"
dataKey="value"
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")}
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")} // Ensure valid RGB for stroke
strokeWidth={2}
dot={{ r: 3, strokeWidth: 1 }}
activeDot={{ r: 5, strokeWidth: 0 }}
fill={color}
fillOpacity={0.5}
// Area charts typically use <Area>, but keeping <Line> based on original code
// If area fill is desired:
// fill={color}
// fillOpacity={0.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
);
}

View File

@ -0,0 +1,8 @@
/*
========================================
File: frontend/features/map/components/chat-bot.tsx
========================================
*/
// This component seems redundant with chat-overlay.tsx.
// Assuming it's removed or its logic is integrated into chat-overlay.tsx.
// If needed, its structure would be similar to chat-overlay.tsx but maybe without the <Overlay> wrapper.

View File

@ -1,50 +1,50 @@
"use client"
/*
========================================
File: frontend/features/map/components/filters-overlay.tsx
========================================
*/
"use client";
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Minimize2, Maximize2 } from "lucide-react"
import { useOverlayContext } from "./overlay-context"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useState } from "react";
import { Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
export function PropertyFilters() {
const { overlays, minimizeOverlay, maximizeOverlay } = useOverlayContext()
const isMinimized = overlays.filters.minimized
export function FiltersOverlay() {
const [area, setArea] = useState("< 30 km");
const [timePeriod, setTimePeriod] = useState("All Time");
const [propertyType, setPropertyType] = useState("House");
const [priceRange, setPriceRange] = useState([5_000_000, 20_000_000]);
const [activeTab, setActiveTab] = useState("basic");
const [area, setArea] = useState("< 30 km")
const [timePeriod, setTimePeriod] = useState("All Time")
const [propertyType, setPropertyType] = useState("House")
const [priceRange, setPriceRange] = useState([5000000, 20000000])
const [activeTab, setActiveTab] = useState("basic")
if (isMinimized) {
return (
<Card className="overlay-card overlay-minimized">
<CardHeader className="p-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium">Filters</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("filters")}>
<Maximize2 className="h-4 w-4" />
</Button>
</CardHeader>
</Card>
)
}
const handleApplyFilters = () => {
console.log("DUMMY: Applying filters:", {
area,
timePeriod,
propertyType,
priceRange, // Include advanced filters state here
});
// In real app: trigger data refetch with these filters
};
return (
<Card className="overlay-card w-[350px] max-h-[80vh] overflow-hidden">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-medium">Property Filters</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("filters")}>
<Minimize2 className="h-4 w-4" />
</Button>
</CardHeader>
<ScrollArea className="h-full max-h-[calc(min(80vh,var(--max-overlay-height))-60px)]">
<CardContent className="p-4">
<Overlay
id="filters"
title="Property Filters"
icon={<Filter className="h-5 w-5" />}
initialPosition="bottom-left"
initialIsOpen={true}
width="350px">
<ScrollArea className="h-[calc(min(70vh,500px))]">
{" "}
{/* Scrollable content */}
<div className="p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="basic">Basic</TabsTrigger>
@ -54,9 +54,11 @@ export function PropertyFilters() {
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-medium">Area Radius</label>
<Label htmlFor="area-radius" className="text-xs font-medium">
Area Radius
</Label>
<Select value={area} onValueChange={setArea}>
<SelectTrigger>
<SelectTrigger id="area-radius">
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
@ -68,9 +70,11 @@ export function PropertyFilters() {
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Time Period</label>
<Label htmlFor="time-period" className="text-xs font-medium">
Time Period
</Label>
<Select value={timePeriod} onValueChange={setTimePeriod}>
<SelectTrigger>
<SelectTrigger id="time-period">
<SelectValue placeholder="Select time period" />
</SelectTrigger>
<SelectContent>
@ -83,9 +87,11 @@ export function PropertyFilters() {
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Property Type</label>
<Label htmlFor="property-type" className="text-xs font-medium">
Property Type
</Label>
<Select value={propertyType} onValueChange={setPropertyType}>
<SelectTrigger>
<SelectTrigger id="property-type">
<SelectValue placeholder="Select property type" />
</SelectTrigger>
<SelectContent>
@ -103,34 +109,26 @@ export function PropertyFilters() {
<div className="space-y-3">
<div>
<div className="flex justify-between mb-1">
<label className="text-xs font-medium">Price Range</label>
<Label htmlFor="price-range" className="text-xs font-medium">
Price Range
</Label>
<span className="text-xs text-muted-foreground">
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(priceRange[0])}{" "}
-{" "}
{new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(priceRange[1])}
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[0])} -{" "}
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[1])} ฿
</span>
</div>
<Slider
id="price-range"
value={priceRange}
min={1000000}
max={50000000}
step={1000000}
min={1_000_000}
max={50_000_000}
step={100_000} // Finer step
onValueChange={setPriceRange}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium">Environmental Factors</label>
<Label className="text-xs font-medium">Environmental Factors</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="low-flood" className="text-xs">
@ -156,12 +154,11 @@ export function PropertyFilters() {
</TabsContent>
</Tabs>
<Button className="mt-4 w-full" size="sm">
<Button className="mt-4 w-full" size="sm" onClick={handleApplyFilters}>
Apply Filters
</Button>
</CardContent>
</div>
</ScrollArea>
</Card>
)
</Overlay>
);
}

View File

@ -0,0 +1,75 @@
/*
========================================
File: frontend/features/map/components/map-container.tsx
========================================
*/
"use client";
import React, { useEffect, useRef } from "react";
import type { MapLocation } from "../types"; // Feature-specific type
interface MapContainerProps {
selectedLocation: MapLocation;
}
export function MapContainer({ selectedLocation }: MapContainerProps) {
const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mapElement = mapRef.current;
console.log("DUMMY MAP: Initializing/updating for:", selectedLocation);
if (mapElement) {
// Placeholder for actual map library integration (e.g., Leaflet, Mapbox GL JS, Google Maps API)
mapElement.innerHTML = `
<div style="
width: 100%; height: 100%;
background-image: url('/placeholder.svg?height=800&width=1200'); /* Using placeholder */
background-size: cover;
background-position: center;
display: flex; align-items: center; justify-content: center;
font-family: sans-serif; color: #555;
border: 1px dashed #aaa;
position: relative; /* Needed for marker positioning */
">
Map Placeholder: Centered on ${selectedLocation.name || "location"} (${selectedLocation.lat.toFixed(
4
)}, ${selectedLocation.lng.toFixed(4)})
<div style="
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 24px; height: 24px;
border-radius: 50%;
background-color: red;
border: 4px solid rgba(255, 100, 100, 0.5);
animation: pulse 1.5s infinite;
"></div>
</div>
<style>
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(255, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); }
}
</style>
`;
// In a real app, you'd initialize the map library here, set view, add layers/markers.
}
// Cleanup function
return () => {
console.log("DUMMY MAP: Cleaning up map instance");
if (mapElement) {
mapElement.innerHTML = ""; // Clear placeholder
// In a real app, you'd properly destroy the map instance here.
}
};
}, [selectedLocation]); // Re-run effect if location changes
return (
<div ref={mapRef} className="absolute inset-0 h-full w-full bg-muted/20 dark:bg-muted/10">
{/* The map library will render into this div */}
</div>
);
}

View File

@ -1,32 +1,40 @@
"use client"
/*
========================================
File: frontend/features/map/components/map-header.tsx
========================================
*/
"use client";
import { ChevronRight } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ThemeToggle } from "@/components/theme-toggle"
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/common/ThemeToggle"; // Import from common
export function MapHeader() {
// Add any map-specific header logic here if needed
return (
<header className="flex h-14 items-center justify-between border-b px-4 bg-background">
<header className="flex h-14 items-center justify-between border-b px-4 bg-background flex-shrink-0">
{/* Breadcrumbs or Title */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/tools" className="hover:text-foreground">
{" "}
{/* Example link */}
Tools
</Link>
<ChevronRight className="h-4 w-4" />
<span className="font-medium text-foreground">Map</span>
</div>
{/* Header Actions */}
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="outline" size="sm" className="ml-2">
Buy & Sell
Dummy Action 1
</Button>
<Button variant="ghost" size="sm">
Send & Receive
Dummy Action 2
</Button>
</div>
</header>
)
);
}

View File

@ -0,0 +1,144 @@
/*
========================================
File: frontend/features/map/components/map-sidebar.tsx
========================================
*/
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Home,
Map, // Changed from Clock
BarChart3, // Changed from Map
Layers, // Changed from FileText
Settings,
SlidersHorizontal, // Changed from PenTool
MessageCircle, // Changed from BarChart3
Info, // Changed from Plane
LineChart,
DollarSign,
MoreHorizontal,
Gift, // Added Gift icon component below
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar"; // Assuming sidebar is a shared UI component structure
export function MapSidebar() {
const pathname = usePathname();
// Define navigation items relevant to the map context or general app navigation shown here
const mainNavItems = [
{ name: "Map View", icon: Map, href: "/map" },
{ name: "Analytics", icon: BarChart3, href: "/map/analytics" }, // Example sub-route
{ name: "Filters", icon: SlidersHorizontal, href: "/map/filters" }, // Example sub-route
{ name: "Data Layers", icon: Layers, href: "/map/layers" }, // Example sub-route
{ name: "Chat", icon: MessageCircle, href: "/map/chat" }, // Example sub-route
{ name: "Model Info", icon: Info, href: "/model-explanation" }, // Link to other feature
{ name: "Settings", icon: Settings, href: "/settings" }, // Example general setting
{ name: "More", icon: MoreHorizontal, href: "/more" }, // Example general setting
];
// Example project-specific items (if sidebar is shared)
const projectNavItems = [
{ name: "Market Trends", icon: LineChart, href: "/projects/trends" },
{ name: "Investment", icon: DollarSign, href: "/projects/investment" },
];
return (
// Using the shared Sidebar component structure
<Sidebar side="left" variant="sidebar" collapsible="icon">
<SidebarHeader>
<Link href="/" className="flex items-center gap-2 font-semibold px-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
B
</div>
{/* Hide text when collapsed */}
<span className="text-xl font-bold group-data-[collapsed]:hidden">BorBann</span>
</Link>
</SidebarHeader>
<SidebarContent className="p-2">
<SidebarMenu>
{mainNavItems.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
asChild // Use asChild if the button itself is a Link or wraps one
variant="default"
size="default"
isActive={pathname === item.href}
tooltip={item.name} // Tooltip shown when collapsed
>
<Link href={item.href}>
<item.icon />
{/* Hide text when collapsed */}
<span className="group-data-[collapsed]:hidden">{item.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
{/* Optional Project Section */}
{/* <SidebarSeparator />
<SidebarGroup>
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{projectNavItems.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton >...</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup> */}
</SidebarContent>
<SidebarFooter>
{/* Footer content like user profile, settings shortcut etc. */}
<div className="flex items-center gap-3 p-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs">
GG
</div>
<div className="group-data-[collapsed]:hidden">
<div className="font-medium text-sm">GG_WPX</div>
<div className="text-xs text-muted-foreground">gg@example.com</div>
</div>
</div>
</SidebarFooter>
</Sidebar>
);
}
// Example Gift Icon (if not using lucide-react)
function GiftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
);
}

View File

@ -1,3 +1,8 @@
/*
========================================
File: frontend/features/map/components/overlay-system/overlay-context.tsx
========================================
*/
"use client";
import React, { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react";
@ -20,7 +25,7 @@ export interface OverlayState {
// Interface for the overlay context
interface OverlayContextType {
overlays: Record<OverlayId, OverlayState>;
registerOverlay: (id: OverlayId, initialState: Partial<OverlayState>) => void;
registerOverlay: (id: OverlayId, initialState: Partial<Omit<OverlayState, "id" | "zIndex">>) => void;
unregisterOverlay: (id: OverlayId) => void;
openOverlay: (id: OverlayId) => void;
closeOverlay: (id: OverlayId) => void;
@ -36,45 +41,52 @@ interface OverlayContextType {
const OverlayContext = createContext<OverlayContextType | undefined>(undefined);
// Default values for overlay state
const defaultOverlayState: Omit<OverlayState, "id" | "title"> = {
const defaultOverlayState: Omit<OverlayState, "id" | "title" | "icon"> = {
isOpen: false,
isMinimized: false,
position: "bottom-right",
zIndex: 10,
position: "bottom-right", // Default position
zIndex: 10, // Starting z-index
};
export function OverlayProvider({ children }: { children: ReactNode }) {
const [overlays, setOverlays] = useState<Record<OverlayId, OverlayState>>({});
const maxZIndexRef = useRef(10);
const maxZIndexRef = useRef(10); // Start z-index from 10
// Get the next z-index value using a ref so it doesn't trigger re-renders
// Get the next z-index value
const getNextZIndex = useCallback(() => {
maxZIndexRef.current++;
maxZIndexRef.current += 1;
return maxZIndexRef.current;
}, []);
// Register a new overlay
const registerOverlay = useCallback((id: OverlayId, initialState: Partial<OverlayState>) => {
setOverlays((prev) => {
if (prev[id]) return prev;
return {
...prev,
[id]: {
...defaultOverlayState,
id,
title: id,
...initialState,
},
};
});
}, []);
const registerOverlay = useCallback(
(id: OverlayId, initialState: Partial<Omit<OverlayState, "id" | "zIndex">>) => {
setOverlays((prev) => {
if (prev[id]) {
console.warn(`Overlay with id "${id}" already registered.`);
return prev;
}
const newZIndex = initialState.isOpen ? getNextZIndex() : defaultOverlayState.zIndex;
return {
...prev,
[id]: {
...defaultOverlayState,
id,
title: id, // Default title to id
...initialState,
zIndex: newZIndex, // Set initial z-index
},
};
});
},
[getNextZIndex]
);
// Unregister an overlay
const unregisterOverlay = useCallback((id: OverlayId) => {
setOverlays((prev) => {
const newOverlays = { ...prev };
delete newOverlays[id];
return newOverlays;
const { [id]: _, ...rest } = prev; // Use destructuring to remove the key
return rest;
});
}, []);
@ -82,14 +94,14 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
const openOverlay = useCallback(
(id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
if (!prev[id] || prev[id].isOpen) return prev;
return {
...prev,
[id]: {
...prev[id],
isOpen: true,
isMinimized: false,
zIndex: getNextZIndex(),
isMinimized: false, // Ensure not minimized when opened
zIndex: getNextZIndex(), // Bring to front
},
};
});
@ -100,33 +112,31 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
// Close an overlay
const closeOverlay = useCallback((id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
if (!prev[id] || !prev[id].isOpen) return prev;
return {
...prev,
[id]: {
...prev[id],
isOpen: false,
},
[id]: { ...prev[id], isOpen: false },
};
});
}, []);
// Toggle an overlay
// Toggle an overlay's open/closed state
const toggleOverlay = useCallback(
(id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
const newState = {
...prev[id],
isOpen: !prev[id].isOpen,
};
if (newState.isOpen) {
newState.isMinimized = false;
newState.zIndex = getNextZIndex();
}
if (!prev[id]) return prev; // Don't toggle non-existent overlays
const willBeOpen = !prev[id].isOpen;
const newZIndex = willBeOpen ? getNextZIndex() : prev[id].zIndex; // Bring to front only if opening
return {
...prev,
[id]: newState,
[id]: {
...prev[id],
isOpen: willBeOpen,
isMinimized: willBeOpen ? false : prev[id].isMinimized, // Maximize when toggling open
zIndex: newZIndex,
},
};
});
},
@ -136,12 +146,13 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
// Minimize an overlay
const minimizeOverlay = useCallback((id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
if (!prev[id] || !prev[id].isOpen || prev[id].isMinimized) return prev; // Only minimize open, non-minimized overlays
return {
...prev,
[id]: {
...prev[id],
isMinimized: true,
// Optionally send to back when minimized: zIndex: defaultOverlayState.zIndex
},
};
});
@ -151,13 +162,13 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
const maximizeOverlay = useCallback(
(id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
if (!prev[id] || !prev[id].isOpen || !prev[id].isMinimized) return prev; // Only maximize minimized overlays
return {
...prev,
[id]: {
...prev[id],
isMinimized: false,
zIndex: getNextZIndex(),
zIndex: getNextZIndex(), // Bring to front when maximized
},
};
});
@ -171,10 +182,7 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
if (!prev[id]) return prev;
return {
...prev,
[id]: {
...prev[id],
position,
},
[id]: { ...prev[id], position },
};
});
}, []);
@ -183,13 +191,12 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
const bringToFront = useCallback(
(id: OverlayId) => {
setOverlays((prev) => {
if (!prev[id]) return prev;
if (!prev[id] || !prev[id].isOpen) return prev; // Only bring open overlays to front
// Avoid getting new zIndex if already on top
if (prev[id].zIndex === maxZIndexRef.current) return prev;
return {
...prev,
[id]: {
...prev[id],
zIndex: getNextZIndex(),
},
[id]: { ...prev[id], zIndex: getNextZIndex() },
};
});
},
@ -213,6 +220,7 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
return <OverlayContext.Provider value={value}>{children}</OverlayContext.Provider>;
}
// Hook to use the overlay context
export function useOverlay() {
const context = useContext(OverlayContext);
if (context === undefined) {
@ -220,4 +228,3 @@ export function useOverlay() {
}
return context;
}

View File

@ -0,0 +1,67 @@
/*
========================================
File: frontend/features/map/components/overlay-system/overlay-dock.tsx
========================================
*/
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useOverlay } from "./overlay-context";
import { cn } from "@/lib/utils";
interface OverlayDockProps {
position?: "bottom" | "right" | "left" | "top"; // Added more positions
className?: string;
}
export function OverlayDock({ position = "bottom", className }: OverlayDockProps) {
const { overlays, toggleOverlay } = useOverlay();
// Filter overlays that have icons defined (and potentially are registered)
const dockableOverlays = Object.values(overlays).filter((overlay) => overlay.icon);
// No need to render if there are no dockable overlays
if (dockableOverlays.length === 0) return null;
// Define CSS classes for different positions
const positionClasses = {
bottom: "fixed bottom-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
right: "fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
left: "fixed left-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
top: "fixed top-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
};
const tooltipSide = {
bottom: "top",
top: "bottom",
left: "right",
right: "left",
} as const;
return (
<TooltipProvider>
<div className={cn(positionClasses[position], className)}>
{dockableOverlays.map((overlay) => (
<div key={overlay.id}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={overlay.isOpen && !overlay.isMinimized ? "default" : "outline"} // Highlight if open and not minimized
size="icon"
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
onClick={() => toggleOverlay(overlay.id)}>
{overlay.icon}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide[position]}>
{overlay.isOpen ? `Hide ${overlay.title}` : `Show ${overlay.title}`}
</TooltipContent>
</Tooltip>
</div>
))}
</div>
</TooltipProvider>
);
}

View File

@ -0,0 +1,219 @@
/*
========================================
File: frontend/features/map/components/overlay-system/overlay.tsx
========================================
*/
"use client";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { X, Minimize2, Maximize2, Move } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useOverlay, type OverlayId, type OverlayPosition } from "./overlay-context";
interface OverlayProps {
id: OverlayId;
title: string;
icon?: React.ReactNode;
initialPosition?: OverlayPosition;
initialIsOpen?: boolean;
className?: string;
children: React.ReactNode;
onClose?: () => void;
showMinimize?: boolean;
width?: string;
height?: string; // Can be 'auto' or specific value like '400px'
maxHeight?: string; // e.g., '80vh'
}
export function Overlay({
id,
title,
icon,
initialPosition = "bottom-right",
initialIsOpen = false,
className,
children,
onClose,
showMinimize = true,
width = "350px",
height = "auto",
maxHeight = "80vh", // Default max height
}: OverlayProps) {
const {
overlays,
registerOverlay,
unregisterOverlay,
closeOverlay,
minimizeOverlay,
maximizeOverlay,
bringToFront,
// Add setPosition if dragging is implemented
} = useOverlay();
const overlayRef = useRef<HTMLDivElement>(null);
// State for dragging logic (Optional, basic example commented out)
// const [isDragging, setIsDragging] = useState(false);
// const [offset, setOffset] = useState({ x: 0, y: 0 });
// Register overlay on mount
useEffect(() => {
registerOverlay(id, {
title,
icon,
position: initialPosition,
isOpen: initialIsOpen,
// Add initial zIndex if needed, otherwise context handles it
});
// Unregister on unmount
return () => unregisterOverlay(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, registerOverlay, unregisterOverlay]); // Only run once on mount/unmount
// Get the current state of this overlay
const overlay = overlays[id];
// --- Optional Dragging Logic ---
// const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
// if (!overlayRef.current) return;
// bringToFront(id);
// setIsDragging(true);
// const rect = overlayRef.current.getBoundingClientRect();
// setOffset({
// x: e.clientX - rect.left,
// y: e.clientY - rect.top,
// });
// // Prevent text selection during drag
// e.preventDefault();
// }, [bringToFront, id]);
// const handleMouseMove = useCallback((e: MouseEvent) => {
// if (!isDragging || !overlayRef.current) return;
// overlayRef.current.style.left = `${e.clientX - offset.x}px`;
// overlayRef.current.style.top = `${e.clientY - offset.y}px`;
// // Remove fixed positioning classes if dragging manually
// overlayRef.current.classList.remove(...Object.values(positionClasses));
// }, [isDragging, offset]);
// const handleMouseUp = useCallback(() => {
// if (isDragging) {
// setIsDragging(false);
// // Optional: Snap to edge or update position state in context
// }
// }, [isDragging]);
// useEffect(() => {
// if (isDragging) {
// window.addEventListener("mousemove", handleMouseMove);
// window.addEventListener("mouseup", handleMouseUp);
// } else {
// window.removeEventListener("mousemove", handleMouseMove);
// window.removeEventListener("mouseup", handleMouseUp);
// }
// return () => {
// window.removeEventListener("mousemove", handleMouseMove);
// window.removeEventListener("mouseup", handleMouseUp);
// };
// }, [isDragging, handleMouseMove, handleMouseUp]);
// --- End Optional Dragging Logic ---
// If the overlay isn't registered yet or isn't open, don't render anything
if (!overlay || !overlay.isOpen) return null;
const handleCloseClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering bringToFront if clicking close
closeOverlay(id);
if (onClose) onClose();
};
const handleMinimizeClick = (e: React.MouseEvent) => {
e.stopPropagation();
minimizeOverlay(id);
};
const handleMaximizeClick = (e: React.MouseEvent) => {
e.stopPropagation();
maximizeOverlay(id);
};
const handleHeaderMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
bringToFront(id);
// handleMouseDown(e); // Uncomment if implementing dragging
};
// Define position classes based on the current state
const positionClasses = {
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
};
// Render minimized state in the dock (handled by OverlayDock now)
if (overlay.isMinimized) {
// Minimized state is now handled by the OverlayDock component based on context state
// This component only renders the full overlay or nothing.
return null;
}
// Render full overlay
return (
<div
ref={overlayRef}
className={cn(
"fixed z-10", // z-index is managed by inline style
positionClasses[overlay.position], // Apply position classes
// Add transition for position changes if needed: 'transition-all duration-300 ease-out'
className
)}
style={{ zIndex: overlay.zIndex }} // Apply dynamic z-index
onClick={() => bringToFront(id)} // Bring to front on any click within the overlay
aria-labelledby={`${id}-title`}
role="dialog" // Use appropriate role
>
<Card
className={cn(
"shadow-lg bg-card/95 backdrop-blur-sm border border-border/50 overflow-hidden flex flex-col", // Added flex flex-col
className
)}
style={{
width,
height, // Use height directly
maxHeight, // Use maxHeight
maxWidth: "calc(100vw - 32px)", // Prevent overlay exceeding viewport width
}}>
{/* Make header draggable */}
<CardHeader
className="pb-2 flex flex-row items-center justify-between cursor-move flex-shrink-0" // Added flex-shrink-0
// onMouseDown={handleHeaderMouseDown} // Uncomment if implementing dragging
>
<CardTitle id={`${id}-title`} className="text-sm font-medium flex items-center gap-2">
{icon && <span className="text-primary">{icon}</span>}
{title}
{/* <Move className="h-3 w-3 text-muted-foreground ml-1 cursor-move" /> */} {/* Optional move icon */}
</CardTitle>
<div className="flex items-center gap-1">
{showMinimize && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={handleMinimizeClick}
title="Minimize">
<Minimize2 className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleCloseClick} title="Close">
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{/* Ensure content area takes remaining space and scrolls if needed */}
<CardContent className="p-0 flex-1 min-h-0 overflow-auto">{children}</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,8 @@
/*
========================================
File: frontend/features/map/components/property-filters.tsx
========================================
*/
// This component seems redundant with filters-overlay.tsx.
// Assuming it's removed or its logic is integrated into filters-overlay.tsx.
// If needed, its structure would be similar to filters-overlay.tsx but maybe without the <Overlay> wrapper.

View File

@ -0,0 +1,38 @@
/*
========================================
File: frontend/features/map/hooks/useMapInteractions.ts (NEW - Dummy)
========================================
*/
import { useState, useCallback } from "react";
import type { MapLocation } from "../types";
/**
* DUMMY Hook: Manages map interaction state like selected markers, zoom level etc.
*/
export function useMapInteractions(initialLocation: MapLocation) {
const [currentLocation, setCurrentLocation] = useState<MapLocation>(initialLocation);
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
const handleMapMove = useCallback((newLocation: MapLocation) => {
console.log("DUMMY Hook: Map moved to", newLocation);
setCurrentLocation(newLocation);
}, []);
const handleMarkerClick = useCallback((markerId: string) => {
console.log("DUMMY Hook: Marker clicked", markerId);
setSelectedMarkerId(markerId);
// Potentially fetch details for this marker via API
}, []);
const clearSelection = useCallback(() => {
setSelectedMarkerId(null);
}, []);
return {
currentLocation,
selectedMarkerId,
handleMapMove,
handleMarkerClick,
clearSelection,
};
}

View File

@ -0,0 +1,41 @@
/*
========================================
File: frontend/features/map/types/index.ts (NEW - Dummy)
========================================
*/
// Types specific only to the Map feature
export interface MapBounds {
north: number;
south: number;
east: number;
west: number;
}
export interface MapLocation {
lat: number;
lng: number;
name?: string;
zoom?: number;
}
// Example type for map layer configuration
export interface MapLayerConfig {
id: string;
name: string;
url: string; // e.g., Tile server URL template
type: "raster" | "vector" | "geojson";
visible: boolean;
opacity?: number;
}
// Example type for data displayed on the map
export interface MapPropertyData {
id: string;
coordinates: [number, number]; // [lng, lat]
price: number;
type: string;
}
// Re-export relevant shared types if needed for convenience
// export type { PointOfInterest } from '@/types/api';

View File

@ -0,0 +1,26 @@
/*
========================================
File: frontend/features/map/utils/mapHelpers.ts (NEW - Dummy)
========================================
*/
import type { MapBounds, MapLocation } from "../types";
/**
* DUMMY Utility: Calculates the center of given map bounds.
*/
export function calculateBoundsCenter(bounds: MapBounds): MapLocation {
const centerLat = (bounds.north + bounds.south) / 2;
const centerLng = (bounds.east + bounds.west) / 2;
console.log("DUMMY Util: Calculating center for bounds:", bounds);
return { lat: centerLat, lng: centerLng };
}
/**
* DUMMY Utility: Formats coordinates for display.
*/
export function formatCoords(location: MapLocation): string {
return `${location.lat.toFixed(4)}, ${location.lng.toFixed(4)}`;
}
// Add other map-specific utility functions here

View File

@ -0,0 +1,51 @@
/*
========================================
File: frontend/features/model-explanation/api/explanationApi.ts (NEW - Dummy)
========================================
*/
import apiClient from "@/services/apiClient";
import type { APIResponse } from "@/types/api";
import type { ModelExplanationData, FeatureImportance } from "../types";
/**
* DUMMY: Fetches data needed for the model explanation page.
*/
export async function fetchModelExplanation(propertyId: string): Promise<APIResponse<ModelExplanationData>> {
console.log(`DUMMY API: Fetching model explanation for property ID: ${propertyId}`);
// return apiClient.get<ModelExplanationData>(`/properties/${propertyId}/explanation`);
// Simulate response
await new Promise((resolve) => setTimeout(resolve, 700));
const dummyExplanation: ModelExplanationData = {
propertyDetails: {
address: `Dummy Property ${propertyId}`,
type: "Condo",
size: 120,
bedrooms: 2,
bathrooms: 2,
age: 3,
floor: 10,
amenities: ["Pool", "Gym"],
predictedPrice: 12500000,
},
similarProperties: [
{ address: "Comp 1", price: 12000000, size: 115, age: 4 },
{ address: "Comp 2", price: 13500000, size: 130, age: 2 },
],
features: [
{ name: "Location", importance: 40, value: "Near BTS", impact: "positive" },
{ name: "Size", importance: 30, value: "120 sqm", impact: "positive" },
{ name: "Age", importance: 15, value: "3 years", impact: "neutral" },
{ name: "Amenities", importance: 10, value: "Pool, Gym", impact: "positive" },
{ name: "Floor", importance: 5, value: "10th", impact: "positive" },
],
environmentalFactors: {
floodRisk: "low",
airQuality: "moderate",
noiseLevel: "low",
},
confidence: 0.91,
priceRange: { lower: 11800000, upper: 13200000 },
};
return { success: true, data: dummyExplanation };
}

View File

@ -0,0 +1,71 @@
/*
========================================
File: frontend/features/model-explanation/components/feature-importance-chart.tsx
========================================
*/
"use client";
import { useTheme } from "next-themes";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "@/components/ui/chart"; // Using shared ui chart components
import type { FeatureImportance } from "../types"; // Feature specific type
interface FeatureImportanceChartProps {
features: FeatureImportance[];
}
export function FeatureImportanceChart({ features }: FeatureImportanceChartProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
// Sort features by importance for consistent display
const sortedFeatures = [...features].sort((a, b) => b.importance - a.importance);
// Define colors based on impact
const getBarColor = (impact: "positive" | "negative" | "neutral") => {
if (impact === "positive") return "#10b981"; // Green
if (impact === "negative") return "#ef4444"; // Red
return "#f59e0b"; // Amber/Yellow for neutral
};
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sortedFeatures} layout="vertical" margin={{ top: 5, right: 30, left: 80, bottom: 5 }}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false} // Typically vertical grid lines are less useful for horizontal bar charts
stroke={isDark ? "#374151" : "#e5e7eb"}
/>
<XAxis
type="number"
domain={[0, 100]} // Assuming importance is a percentage
tickFormatter={(value) => `${value}%`}
stroke={isDark ? "#9ca3af" : "#6b7280"}
/>
<YAxis
dataKey="name"
type="category"
width={80} // Adjust width based on longest label
stroke={isDark ? "#9ca3af" : "#6b7280"}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, "Importance"]}
labelFormatter={(label: string) => `Feature: ${label}`} // Show feature name in tooltip
contentStyle={{
backgroundColor: isDark ? "#1f2937" : "white",
borderRadius: "0.375rem",
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
fontSize: "0.75rem",
color: isDark ? "#e5e7eb" : "#1f2937",
}}
/>
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
{sortedFeatures.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.impact)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,94 @@
/*
========================================
File: frontend/features/model-explanation/components/price-comparison-chart.tsx
========================================
*/
"use client";
import { useTheme } from "next-themes";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
Cell,
} from "@/components/ui/chart"; // Using shared ui chart components
import type { ComparableProperty, PropertyBaseDetails } from "../types"; // Feature specific types
interface PriceComparisonChartProps {
property: PropertyBaseDetails & { name: string }; // Add name for the primary property
comparisons: ComparableProperty[];
}
export function PriceComparisonChart({ property, comparisons }: PriceComparisonChartProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
// Combine property and comparisons for chart data
// Ensure the property being explained is included and identifiable
const data = [
{ ...property }, // Keep all details for tooltip if needed
...comparisons.map((c) => ({ ...c, name: c.address })), // Use address as name for comparisons
];
// Format the price for display
const formatPrice = (value: number) => {
return new Intl.NumberFormat("th-TH", {
style: "currency",
currency: "THB",
notation: "compact", // Use compact notation like 15M
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
};
// Custom tooltip content
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload; // Get the data point for this bar
return (
<div className="p-2 bg-background border rounded shadow-lg text-xs">
<p className="font-bold">{label}</p>
<p>Price: {formatPrice(data.price)}</p>
<p>Size: {data.size} sqm</p>
<p>Age: {data.age} years</p>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 10, left: 40, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={isDark ? "#374151" : "#e5e7eb"} />
<XAxis dataKey="name" stroke={isDark ? "#9ca3af" : "#6b7280"} fontSize={10} interval={0} />
<YAxis
tickFormatter={(value) => formatPrice(value)}
stroke={isDark ? "#9ca3af" : "#6b7280"}
fontSize={10}
width={40}
/>
<Tooltip
cursor={{ fill: isDark ? "rgba(107, 114, 128, 0.2)" : "rgba(209, 213, 219, 0.4)" }} // Subtle hover effect
content={<CustomTooltip />} // Use custom tooltip
wrapperStyle={{ zIndex: 100 }} // Ensure tooltip is on top
/>
{/* <Legend /> // Legend might be redundant if XAxis labels are clear */}
<Bar dataKey="price" name="Price" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.name === property.name ? "#3b82f6" : "#6b7280"} // Highlight the main property
fillOpacity={entry.name === property.name ? 1 : 0.7}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,50 @@
/*
========================================
File: frontend/features/model-explanation/types/index.ts (NEW - Dummy)
========================================
*/
// Types specific to the Model Explanation feature
export interface FeatureImportance {
name: string;
importance: number; // e.g., percentage 0-100
value: string | number; // The actual value for the property being explained
impact: "positive" | "negative" | "neutral";
}
export interface ComparableProperty {
address: string;
price: number;
size: number;
age: number;
// Add other relevant comparison fields if needed
}
export interface PropertyBaseDetails {
address: string;
type: string;
size: number;
bedrooms?: number;
bathrooms?: number;
age: number;
floor?: number;
amenities?: string[];
predictedPrice: number;
}
export interface EnvironmentalFactors {
floodRisk: "low" | "moderate" | "high" | "unknown";
airQuality: "good" | "moderate" | "poor" | "unknown";
noiseLevel: "low" | "moderate" | "high" | "unknown";
// Add other factors like proximity scores etc.
}
export interface ModelExplanationData {
propertyDetails: PropertyBaseDetails;
similarProperties: ComparableProperty[];
features: FeatureImportance[];
environmentalFactors: EnvironmentalFactors;
confidence: number; // e.g., 0.92 for 92%
priceRange: { lower: number; upper: number };
}

View File

@ -1,19 +1,37 @@
import * as React from "react"
/*
========================================
File: frontend/hooks/use-mobile.tsx
========================================
*/
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768; // Standard md breakpoint
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
export function useIsMobile(): boolean {
// Initialize state based on current window size (or undefined if SSR)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
// Ensure this runs only client-side
if (typeof window === "undefined") {
return;
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
const handleResize = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
// Set initial state correctly after mount
handleResize();
window.addEventListener("resize", handleResize);
// Cleanup listener on unmount
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty dependency array ensures this runs only once on mount and cleanup on unmount
// Return false during SSR or initial client render before effect runs
return isMobile ?? false;
}

View File

@ -1,106 +1,106 @@
"use client"
/*
========================================
File: frontend/hooks/use-toast.ts
========================================
*/
"use client";
// Inspired by react-hot-toast library
import * as React from "react"
import * as React from "react";
// Import types from the actual Toast component location
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1; // Show only one toast at a time
const TOAST_REMOVE_DELAY = 1000000; // A very long time (effectively manual dismiss only)
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[]
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
// Slice ensures the limit is enforced
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
// Side effect: schedule removal for dismissed toasts
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
addToRemoveQueue(toast.id);
});
}
return {
@ -109,48 +109,48 @@ export const reducer = (state: State, action: Action): State => {
t.id === toastId || toastId === undefined
? {
...t,
open: false,
open: false, // Trigger the close animation
}
: t
),
}
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
toasts: [], // Remove all toasts
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
};
}
}
};
const listeners: Array<(state: State) => void> = []
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] }
let memoryState: State = { toasts: [] }; // In-memory state
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState)
})
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
@ -159,36 +159,37 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
if (!open) dismiss(); // Ensure dismiss is called when the toast closes itself
},
},
})
});
return {
id: id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
// Clean up listener
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]); // Only re-subscribe if state instance changes (it shouldn't)
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
};
}
export { useToast, toast }
export { useToast, toast };

View File

@ -1,6 +1,14 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
/*
========================================
File: frontend/lib/utils.ts
========================================
*/
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Utility function to merge Tailwind classes with clsx */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
// Add other general utility functions here if needed

View File

@ -1,7 +1,22 @@
import type { NextConfig } from "next";
/** @type {import('next').NextConfig} */
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true, // Recommended for development
// Add other Next.js configurations here as needed
// Example: environment variables accessible on the client-side
// env: {
// NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
// },
// Example: image optimization domains
// images: {
// remotePatterns: [
// {
// protocol: 'https',
// hostname: 'example.com',
// },
// ],
// },
};
export default nextConfig;

View File

@ -61,7 +61,8 @@
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.6",
"zod": "^3.24.1"
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -171,6 +171,9 @@ importers:
zod:
specifier: ^3.24.1
version: 3.24.2
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0))
devDependencies:
'@eslint/eslintrc':
specifier: ^3
@ -2926,6 +2929,24 @@ packages:
zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': 19.0.10
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -5939,3 +5960,9 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.24.2: {}
zustand@5.0.3(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0)):
optionalDependencies:
'@types/react': 19.0.10
react: 19.0.0
use-sync-external-store: 1.4.0(react@19.0.0)

View File

@ -0,0 +1,106 @@
/*
========================================
File: frontend/services/apiClient.ts (NEW - Dummy)
========================================
*/
import type { APIResponse } from "@/types/api"; // Import shared response type
// --- Dummy Auth Token ---
// In a real app, this would come from localStorage, context, or a state manager after login
const DUMMY_AUTH_TOKEN = "Bearer dummy-jwt-token-12345";
/**
* Base function for making API requests.
* Includes dummy authorization header.
*/
async function fetchApi<T = any>(endpoint: string, options: RequestInit = {}): Promise<APIResponse<T>> {
const url = `${process.env.NEXT_PUBLIC_API_URL || "/api/v1"}${endpoint}`;
const defaultHeaders: HeadersInit = {
"Content-Type": "application/json",
Authorization: DUMMY_AUTH_TOKEN, // Add dummy token here
};
const config: RequestInit = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
};
console.log(`DUMMY API Client: Requesting ${config.method || "GET"} ${url}`);
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, Math.random() * 300 + 100));
try {
// --- Simulate API Responses ---
// You can add more sophisticated simulation based on the endpoint
if (endpoint.includes("error")) {
console.warn(`DUMMY API Client: Simulating error for ${url}`);
return { success: false, error: "Simulated server error" };
}
if (config.method === "POST" || config.method === "PUT" || config.method === "DELETE") {
// Simulate simple success for modification requests
console.log(`DUMMY API Client: Simulating success for ${config.method} ${url}`);
return { success: true, data: { message: "Operation successful (simulated)" } as T };
}
// Simulate success for GET requests (return empty array or specific data based on endpoint)
console.log(`DUMMY API Client: Simulating success for GET ${url}`);
let responseData: any = [];
if (endpoint.includes("/map/pois")) responseData = []; // Let feature api add dummy data
if (endpoint.includes("/properties")) responseData = []; // Example
// Add more specific endpoint data simulation if needed
return { success: true, data: responseData as T };
// --- Real Fetch Logic (keep commented for dummy) ---
// const response = await fetch(url, config);
//
// if (!response.ok) {
// let errorData;
// try {
// errorData = await response.json();
// } catch (e) {
// errorData = { detail: response.statusText || "Unknown error" };
// }
// const errorMessage = errorData?.detail || `HTTP error ${response.status}`;
// console.error(`API Error (${response.status}) for ${url}:`, errorMessage);
// return { success: false, error: errorMessage, details: errorData };
// }
//
// // Handle cases with no content
// if (response.status === 204) {
// return { success: true, data: null as T };
// }
//
// const data: T = await response.json();
// return { success: true, data };
// --- End Real Fetch Logic ---
} catch (error) {
console.error(`Network or other error for ${url}:`, error);
const errorMessage = error instanceof Error ? error.message : "Network error or invalid response";
return { success: false, error: errorMessage };
}
}
// --- Convenience Methods ---
const apiClient = {
get: <T = any>(endpoint: string, options?: RequestInit) => fetchApi<T>(endpoint, { ...options, method: "GET" }),
post: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
fetchApi<T>(endpoint, { ...options, method: "POST", body: JSON.stringify(body) }),
put: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
fetchApi<T>(endpoint, { ...options, method: "PUT", body: JSON.stringify(body) }),
delete: <T = any>(endpoint: string, options?: RequestInit) => fetchApi<T>(endpoint, { ...options, method: "DELETE" }),
patch: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
fetchApi<T>(endpoint, { ...options, method: "PATCH", body: JSON.stringify(body) }),
};
export default apiClient;

View File

@ -0,0 +1,43 @@
/*
========================================
File: frontend/store/mapStore.ts (NEW - Dummy using Zustand)
========================================
*/
import { create } from "zustand";
import type { MapLocation, MapLayerConfig } from "@/features/map/types"; // Import types
interface MapState {
currentLocation: MapLocation;
zoomLevel: number;
activeLayers: MapLayerConfig[];
isLoading: boolean;
setLocation: (location: MapLocation) => void;
setZoom: (zoom: number) => void;
toggleLayer: (layerId: string) => void;
setLoading: (loading: boolean) => void;
}
export const useMapStore = create<MapState>((set) => ({
// Initial State
currentLocation: { lat: 13.7563, lng: 100.5018, name: "Bangkok" },
zoomLevel: 12,
activeLayers: [
// Example initial layer
{ id: "base-tiles", name: "Base Map", url: "dummy-tile-url", type: "raster", visible: true },
],
isLoading: false,
// Actions
setLocation: (location) => set({ currentLocation: location }),
setZoom: (zoom) => set({ zoomLevel: zoom }),
toggleLayer: (layerId) =>
set((state) => ({
activeLayers: state.activeLayers.map((layer) =>
layer.id === layerId ? { ...layer, visible: !layer.visible } : layer
),
})),
setLoading: (loading) => set({ isLoading: loading }),
}));
// Usage in a component:
// const { currentLocation, setLocation, isLoading } = useMapStore();

View File

@ -1,15 +1,17 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme"; // Import default theme
const config = {
darkMode: ["class"],
darkMode: ["class"], // Use class-based dark mode
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{ts,tsx}", // Scan app directory
"./components/**/*.{ts,tsx}", // Scan components directory (common and ui)
"./features/**/*.{ts,tsx}", // <= NEW: Scan features directory
// Remove older paths if no longer relevant
// "./pages/**/*.{ts,tsx}", // Likely remove if using App Router only
// "./src/**/*.{ts,tsx}", // Remove if code is not in src/
],
prefix: "",
prefix: "", // No prefix
theme: {
container: {
center: true,
@ -19,7 +21,12 @@ const config = {
},
},
extend: {
// Add sans-serif font family using CSS variable defined in layout.tsx
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
colors: {
// Keep Shadcn UI color definitions using CSS variables
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
@ -53,15 +60,16 @@ const config = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// Sidebar specific colors (ensure these variables are defined in globals.css)
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
accent: {
DEFAULT: "hsl(var(--sidebar-accent))",
foreground: "hsl(var(--sidebar-accent-foreground))",
},
},
},
borderRadius: {
@ -78,14 +86,20 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
// Add caret-blink keyframes if not already present via a plugin
"caret-blink": {
"0%, 50%, 100%": { opacity: "1" },
"25%, 75%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1s ease-in-out infinite", // Add caret animation
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [require("tailwindcss-animate")], // Keep animate plugin
} satisfies Config;
export default config;

View File

@ -1,27 +1,56 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2017", // Keep target
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"strict": true, // Keep strict mode
"noEmit": true, // Next.js handles emitting
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"module": "esnext", // Use esnext module system
"moduleResolution": "bundler", // Recommended for modern TS/JS
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "preserve", // Let Next.js handle JSX transform
"incremental": true,
"plugins": [
{
"name": "next"
}
],
// Ensure path aliases cover the new structure
"paths": {
"@/*": ["./*"]
"@/*": ["./*"], // Base alias
"@/components/*": ["./components/*"],
"@/lib/*": ["./lib/*"],
"@/hooks/*": ["./hooks/*"],
"@/features/*": ["./features/*"],
"@/types/*": ["./types/*"],
"@/services/*": ["./services/*"],
"@/store/*": ["./store/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
// Update include paths
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
// Add specific includes if needed, but '**/*.ts/tsx' usually covers it
// "features/**/*.ts",
// "features/**/*.tsx",
// "components/**/*.ts",
// "components/**/*.tsx",
// "lib/**/*.ts",
// "hooks/**/*.ts",
// "types/**/*.ts",
// "services/**/*.ts",
// "store/**/*.ts",
"eslint.config.mjs", // Include modern ESLint config
"postcss.config.mjs" // Include modern PostCSS config
],
"exclude": [
"node_modules"
// Add other excludes if necessary
]
}

41
frontend/types/api.ts Normal file
View File

@ -0,0 +1,41 @@
/*
========================================
File: frontend/types/api.ts (NEW - Dummy Shared Types)
========================================
*/
/** Generic API Response Structure */
export type APIResponse<T> = { success: true; data: T } | { success: false; error: string; details?: any };
/** Represents a Point of Interest (can be property, cafe, etc.) */
export interface PointOfInterest {
id: string;
lat: number;
lng: number;
name: string;
type: string; // e.g., 'property', 'cafe', 'park', 'station'
price?: number; // Optional: for properties
rating?: number; // Optional: for amenities
// Add other relevant shared fields
}
/** Basic Property Information */
export interface PropertySummary {
id: string;
address: string;
lat: number;
lng: number;
price: number;
type: string; // e.g., 'Condo', 'House'
size?: number; // sqm
}
/** User representation */
export interface User {
id: string;
username: string;
email: string;
// Add roles or other relevant fields
}
// Add other globally shared types (e.g., PipelineStatus, DataSourceType if needed FE side)

12
frontend/types/index.ts Normal file
View File

@ -0,0 +1,12 @@
/*
========================================
File: frontend/types/index.ts (NEW - Barrel File)
========================================
*/
// Re-export shared types for easier importing
export * from "./api";
// You can add other shared types here or export from other files in this directory
// export * from './user';
// export * from './settings';