big update

This commit is contained in:
2025-08-16 12:57:07 -05:00
parent 508d1179dc
commit 86b1eaf6f7
100 changed files with 14935 additions and 1871 deletions

1
ditch-the-agent/.env Normal file
View File

@@ -0,0 +1 @@
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,8 @@
"@mui/material": "^5.15.14",
"@mui/x-data-grid": "^7.2.0",
"@mui/x-data-grid-generator": "^7.2.0",
"@react-google-maps/api": "^2.20.7",
"@vis.gl/react-google-maps": "^1.5.4",
"axios": "^1.10.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
@@ -39,6 +41,7 @@
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",

View File

@@ -1,19 +1,31 @@
import axios from "axios"
import Cookies from 'js-cookie'
import axios from 'axios';
import Cookies from 'js-cookie';
const baseURL = 'http://127.0.0.1:8010/api/';
//const baseURL = 'https://backend.ditchtheagent.com/api/';
export const axiosRealEstateApi = axios.create({
baseURL: 'https://api.realestateapi.com/v2/',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70',
'X-User-Id': 'UniqueUserIdentifier',
},
});
export const axiosWalkScoreApiInstance = axios.create({
baseURL: 'https://api.walkscore.com/',
timeout: 5000,
});
export const axiosInstance = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
"Authorization": 'JWT ' + localStorage.getItem('access_token'),
Authorization: 'JWT ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
'Accept': 'application/json',
}
Accept: 'application/json',
},
});
export const cleanAxiosInstance = axios.create({
@@ -21,8 +33,8 @@ export const cleanAxiosInstance = axios.create({
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
Accept: 'application/json',
},
});
export const axiosInstanceCSRF = axios.create({
@@ -32,30 +44,30 @@ export const axiosInstanceCSRF = axios.create({
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
},
withCredentials: true,
}
);
});
axiosInstance.interceptors.request.use(config => {
axiosInstance.interceptors.request.use((config) => {
config.timeout = 100000;
return config;
})
});
axiosInstance.interceptors.response.use(
response => response,
error => {
(response) => response,
(error) => {
const originalRequest = error.config;
// Prevent infinite loop
if (error.response.status === 401 && originalRequest.url === baseURL + '/token/refresh/') {
window.location.href = '/signin/';
window.location.href = '/authentication/login/';
//console.log('remove the local storage here')
return Promise.reject(error);
}
if(error.response.data.code === "token_not_valid" &&
if (
error.response.data.code === 'token_not_valid' &&
error.response.status == 401 &&
error.response.statusText == 'Unauthorized')
{
error.response.statusText == 'Unauthorized'
) {
const refresh_token = localStorage.getItem('refresh_token');
if (refresh_token) {
@@ -65,7 +77,9 @@ axiosInstance.interceptors.response.use(
//console.log(tokenParts.exp)
if (tokenParts.exp > now) {
return axiosInstance.post('/token/refresh/', {refresh: refresh_token}).then((response) => {
return axiosInstance
.post('/token/refresh/', { refresh: refresh_token })
.then((response) => {
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
@@ -73,24 +87,19 @@ axiosInstance.interceptors.response.use(
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
return axiosInstance(originalRequest);
}).catch(err => {
console.log(err)
})
.catch((err) => {
console.log(err);
});
} else {
console.log('Refresh token is expired');
window.location.href = '/signin/';
window.location.href = '/authentication/login/';
}
} else {
console.log('Refresh token not available');
window.location.href = '/signin/';
window.location.href = '/authentication/login/';
}
}
return Promise.reject(error);
}
},
);

View File

@@ -0,0 +1,29 @@
// src/templates/CategoryGridTemplate.tsx
import React, { ReactNode } from 'react';
import { Grid } from '@mui/material';
import { GenericCategory } from 'types';
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
categories: TCategory[];
onSelectCategory: (categoryId: string) => void;
renderCategoryCard: (category: TCategory, onSelect: (categoryId: string) => void) => ReactNode;
}
function CategoryGridTemplate<TCategory extends GenericCategory>({
categories,
onSelectCategory,
renderCategoryCard,
}: CategoryGridTemplateProps<TCategory>) {
return (
<Grid container spacing={3}>
{categories.map((category) => (
<Grid item xs={12} sm={6} md={4} key={category.id}>
{renderCategoryCard(category, onSelectCategory)}
</Grid>
))}
</Grid>
);
}
export default CategoryGridTemplate;

View File

@@ -0,0 +1,67 @@
// src/templates/DashboardTemplate.tsx
import React, { useState, ReactNode } from 'react';
import { Container, Typography, Box, Button } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { GenericCategory, GenericItem } from 'types';
interface DashboardTemplateProps<TCategory extends GenericCategory, TItem extends GenericItem> {
pageTitle: string;
data: {
categories: TCategory[];
items: TItem[];
};
renderCategoryGrid: (
categories: TCategory[],
onSelectCategory: (categoryId: string) => void,
) => ReactNode;
renderItemListDetail: (
selectedCategory: TCategory,
itemsInSelectedCategory: TItem[],
onBack: () => void,
) => ReactNode;
}
function DashboardTemplate<TCategory extends GenericCategory, TItem extends GenericItem>({
pageTitle,
data,
renderCategoryGrid,
renderItemListDetail,
}: DashboardTemplateProps<TCategory, TItem>) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const handleSelectCategory = (categoryId: string) => {
setSelectedCategoryId(categoryId);
};
const handleBackToCategories = () => {
setSelectedCategoryId(null);
};
const selectedCategory = selectedCategoryId
? data.categories.find((cat) => cat.id === selectedCategoryId)
: null;
const itemsInSelectedCategory = selectedCategoryId
? data.items.filter((item: any) => item.categoryId === selectedCategoryId) // Assuming items have a categoryId field
: [];
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
{pageTitle}
</Typography>
{selectedCategoryId && selectedCategory ? (
<Box>
<Button startIcon={<ArrowBackIcon />} onClick={handleBackToCategories} sx={{ mb: 2 }}>
Back to {pageTitle} Categories
</Button>
{renderItemListDetail(selectedCategory, itemsInSelectedCategory, handleBackToCategories)}
</Box>
) : (
renderCategoryGrid(data.categories, handleSelectCategory)
)}
</Container>
);
}
export default DashboardTemplate;

View File

@@ -1,7 +1,28 @@
import { ReactElement, useState, useEffect, useRef, ChangeEvent, KeyboardEvent } from 'react';
import {
ReactElement,
useState,
useEffect,
useRef,
ChangeEvent,
KeyboardEvent,
useContext,
} from 'react';
import { MessageSquareText, Minus, X, Send } from 'lucide-react'; // Using lucide-react for icons
import { Box, Button, AppBar, Typography, useTheme, Fab, TextField, Paper, Toolbar, IconButton } from '@mui/material';
import {
Box,
Button,
AppBar,
Typography,
useTheme,
Fab,
TextField,
Paper,
Toolbar,
IconButton,
} from '@mui/material';
import { ChatMessage, WebSocketContext } from 'contexts/WebSocketContext';
import { AccountContext } from 'contexts/AccountContext';
import FormattedListingText from './base/FormattedListingText';
interface FloatingActionButtonProps {
onClick: () => void;
@@ -9,7 +30,6 @@ interface FloatingActionButtonProps {
const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
return (
<Fab
color="secondary"
aria-label="open chat"
@@ -30,11 +50,6 @@ const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
);
};
interface ChatMessage {
text: string;
sender: 'user' | 'ai';
}
interface ChatPaneProps {
showChat: boolean;
isMinimized: boolean;
@@ -42,7 +57,6 @@ interface ChatPaneProps {
closeChat: () => void;
}
// Chat Pane Component
const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPaneProps) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -52,8 +66,34 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
const [isLoading, setIsLoading] = useState<boolean>(false);
// Ref for the messages container to scroll to the bottom
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageRef = useRef('');
const [currentMessage, setCurrentMessage] = useState<string>('');
const theme = useTheme();
const { account, accountLoading } = useContext(AccountContext);
const { subscribe, unsubscribe, socket, sendMessages } = useContext(WebSocketContext);
useEffect(() => {
if (accountLoading) return;
const channelName = `ACCOUNT_ID_${account?.email}`;
subscribe(channelName, (message: string) => {
if (message === 'BEGINING_OF_THE_WORLD') {
} else if (message === 'END_OF_THE_WORLD') {
const deepCopiedMessage = structuredClone(messageRef.current);
setMessages((prevMessages) => [...prevMessages, { text: deepCopiedMessage, sender: 'ai' }]);
messageRef.current = '';
setCurrentMessage(messageRef.current);
setIsLoading(false);
} else {
messageRef.current += message;
setCurrentMessage(messageRef.current);
}
});
return () => {
unsubscribe(channelName);
};
}, [account, subscribe, unsubscribe]);
// Scroll to the bottom of the chat window whenever messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -62,56 +102,73 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
// Function to send a message to the AI
const handleSendMessage = async () => {
if (inputMessage.trim() === '') return;
const newUserMessage: ChatMessage = { text: inputMessage, sender: 'user' };
setMessages((prevMessages: ChatMessage[]) => [...prevMessages, newUserMessage]);
setInputMessage(''); // Clear input field
setIsLoading(true); // Show loading indicator
try {
// Construct chat history for the API call
let chatHistory = messages.map(msg => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [{ text: msg.text }]
}));
chatHistory.push({ role: "user", parts: [{ text: newUserMessage.text }] });
const payload = { contents: chatHistory };
const apiKey = ""; // Leave this as-is; Canvas will provide the API key at runtime
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.candidates && result.candidates.length > 0 &&
result.candidates[0].content && result.candidates[0].content.parts &&
result.candidates[0].content.parts.length > 0) {
const aiResponseText = result.candidates[0].content.parts[0].text;
setMessages((prevMessages) => [...prevMessages, { text: aiResponseText, sender: 'ai' }]);
} else {
// Handle cases where the response structure is unexpected or content is missing
console.error("Unexpected API response structure:", result);
setMessages((prevMessages) => [...prevMessages, { text: "Error: Could not get a response from AI.", sender: 'ai' }]);
}
} catch (error) {
console.error('Error fetching AI response:', error);
setMessages((prevMessages) => [...prevMessages, { text: "Error: Failed to connect to AI.", sender: 'ai' }]);
} finally {
setIsLoading(false); // Hide loading indicator
if (account !== undefined) {
const newMessage: ChatMessage = {
text: inputMessage,
sender: 'user',
};
sendMessages([...messages, newMessage]);
setIsLoading(true);
setMessages((prevMessages) => [...prevMessages, newMessage]);
}
// const newUserMessage: ChatMessage = { text: inputMessage, sender: 'user' };
// setMessages((prevMessages: ChatMessage[]) => [...prevMessages, newUserMessage]);
// setInputMessage(''); // Clear input field
// setIsLoading(true); // Show loading indicator
// try {
// // Construct chat history for the API call
// let chatHistory = messages.map((msg) => ({
// role: msg.sender === 'user' ? 'user' : 'model',
// parts: [{ text: msg.text }],
// }));
// chatHistory.push({ role: 'user', parts: [{ text: newUserMessage.text }] });
// const payload = { contents: chatHistory };
// const apiKey = ''; // Leave this as-is; Canvas will provide the API key at runtime
// const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
// const response = await fetch(apiUrl, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(payload),
// });
// const result = await response.json();
// if (
// result.candidates &&
// result.candidates.length > 0 &&
// result.candidates[0].content &&
// result.candidates[0].content.parts &&
// result.candidates[0].content.parts.length > 0
// ) {
// const aiResponseText = result.candidates[0].content.parts[0].text;
// setMessages((prevMessages) => [...prevMessages, { text: aiResponseText, sender: 'ai' }]);
// } else {
// // Handle cases where the response structure is unexpected or content is missing
// console.error('Unexpected API response structure:', result);
// setMessages((prevMessages) => [
// ...prevMessages,
// { text: 'Error: Could not get a response from AI.', sender: 'ai' },
// ]);
// }
// } catch (error) {
// console.error('Error fetching AI response:', error);
// setMessages((prevMessages) => [
// ...prevMessages,
// { text: 'Error: Failed to connect to AI.', sender: 'ai' },
// ]);
// } finally {
// setIsLoading(false); // Hide loading indicator
// }
};
if (!showChat) return null; // Don't render anything if chat is not shown
return (
<Box
sx={{
position: 'fixed',
@@ -127,7 +184,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
zIndex: 40,
width: isMinimized ? '320px' : '384px', // w-80 (320px) vs w-96 (384px)
height: isMinimized ? '64px' : '485px', // h-16 (64px) vs h-[600px]
'@media (min-width: 768px)': { // md: breakpoint
'@media (min-width: 768px)': {
// md: breakpoint
height: isMinimized ? '64px' : 'calc(100vh - 130px)', // md:h-[calc(100vh-80px)]
},
border: '1px solid',
@@ -135,12 +193,16 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
}}
>
{/* Chat Header */}
<AppBar position="static" color='inherit' sx={{
backgroundColor: 'background.paper'
}}>
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}> {/* minHeight to match h-16 for minimized */}
<AppBar
position="static"
color="inherit"
sx={{
backgroundColor: 'background.paper',
}}
>
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}>
{' '}
{/* minHeight to match h-16 for minimized */}
<Typography variant="h6" component="div">
AI Assistant
</Typography>
@@ -148,15 +210,11 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
<IconButton
color="inherit"
onClick={toggleMinimize}
aria-label={isMinimized ? "Maximize chat" : "Minimize chat"}
aria-label={isMinimized ? 'Maximize chat' : 'Minimize chat'}
>
<Minus size={20} />
</IconButton>
<IconButton
color="inherit"
onClick={closeChat}
aria-label="Close chat"
>
<IconButton color="inherit" onClick={closeChat} aria-label="Close chat">
<X size={20} />
</IconButton>
</Box>
@@ -200,7 +258,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
borderBottomLeftRadius: msg.sender === 'user' ? '12px' : 0,
}}
>
<Typography variant="body2">{msg.text}</Typography>
{/*<Typography variant="body2">{msg.text}</Typography>*/}
<FormattedListingText text={msg.text} />
</Paper>
</Box>
))}
@@ -218,9 +277,19 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>.</Typography>
<Typography component="span" className="animate-bounce delay-100" sx={{ mr: 0.5 }}>.</Typography>
<Typography component="span" className="animate-bounce delay-200">.</Typography>
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>
.
</Typography>
<Typography
component="span"
className="animate-bounce delay-100"
sx={{ mr: 0.5 }}
>
.
</Typography>
<Typography component="span" className="animate-bounce delay-200">
.
</Typography>
</Box>
</Paper>
</Box>
@@ -249,7 +318,9 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
size="small"
value={inputMessage}
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputMessage(e.target.value)}
onKeyPress={(e: KeyboardEvent<HTMLInputElement>) => e.key === 'Enter' && handleSendMessage()}
onKeyPress={(e: KeyboardEvent<HTMLInputElement>) =>
e.key === 'Enter' && handleSendMessage()
}
placeholder="Type your message..."
disabled={isLoading}
sx={{
@@ -321,12 +392,10 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
`}
</style>
</Box>
);
};
const FloatingChatButton = (): ReactElement =>
{
const FloatingChatButton = (): ReactElement => {
const [showChat, setShowChat] = useState<boolean>(false);
// State to control if the chat pane is minimized
const [isMinimized, setIsMinimized] = useState<boolean>(false);
@@ -362,10 +431,7 @@ const FloatingChatButton = (): ReactElement =>
closeChat={closeChat}
/>
</div>
)
}
);
};
export default FloatingChatButton;

View File

@@ -0,0 +1,78 @@
// src/templates/ItemListDetailTemplate.tsx
import React, { useState, useEffect, ReactNode } from 'react';
import { Box, Grid, List, ListItem, ListItemText, Typography, Paper, Button, Stack, IconButton } from '@mui/material';
import { GenericCategory, GenericItem } from 'types';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
interface ItemListDetailTemplateProps<TCategory extends GenericCategory, TItem extends GenericItem> {
category: TCategory;
items: TItem[];
onBack: () => void;
renderListItem: (item: TItem, isSelected: boolean, onSelect: (itemId: string) => void) => ReactNode;
renderItemDetail: (item: TItem) => ReactNode;
}
function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends GenericItem>({
category,
items,
onBack,
renderListItem,
renderItemDetail,
}: ItemListDetailTemplateProps<TCategory, TItem>) {
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
// Default to the first item in the list
let temp = null
useEffect(() => {
if (items.length > 0) {
temp = items[0].id
setSelectedItemId(items[0].id);
} else {
setSelectedItemId(null);
}
}, [items]);
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
console.log(selectedItemId, selectedItem)
const handleItemSelect = (itemId: string) => {
setSelectedItemId(itemId);
};
return (
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
<Stack direction="row">
<IconButton size='small' color="inherit" onClick={onBack} sx={{mr:1}}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" gutterBottom>{category.name} List</Typography>
</Stack>
<List>
{items.map((item) => (
<Box key={item.id}>
{renderListItem(item, selectedItem?.id === item.id, handleItemSelect)}
</Box>
))}
</List>
</Paper>
</Grid>
<Grid item xs={12} md={8}>
{selectedItem ? (
renderItemDetail(selectedItem)
) : (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h6" color="text.secondary">Select an item to view details</Typography>
</Paper>
)}
</Grid>
</Grid>
);
}
export default ItemListDetailTemplate;

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface FormattedListingTextProps {
text: string;
}
const FormattedListingText: React.FC<FormattedListingTextProps> = ({ text }) => {
const parts = text.split(/\*\*(.*?)\*\*/g);
return (
<div style={{ whiteSpace: 'pre-line' }}>
{parts.map((part, index) =>
index % 2 === 1 ?
<strong key={index}>{part}</strong> :
part
)}
</div>
);
};
export default FormattedListingText;

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react';
import { useMapsLibrary } from '@vis.gl/react-google-maps';
export const GeocodeComponent = () => {
// Use state to store the geocoding results
const [latLng, setLatLng] = useState<{ lat: number; lng: number } | null>(null);
const [address, setAddress] = useState<string>('');
// Use the hook to load the geocoding library
const geocodingLibrary = useMapsLibrary('geocoding');
// Create an instance of the Geocoder once the library is loaded
useEffect(() => {
if (!geocodingLibrary || !address) {
return;
}
const geocoder = new geocodingLibrary.Geocoder();
// Perform the geocode request
geocoder.geocode(
{
address: address,
},
(results, status) => {
if (status === 'OK' && results) {
// If a result is found, extract the lat/lng
const location = results[0].geometry.location;
setLatLng({ lat: location.lat(), lng: location.lng() });
} else {
setLatLng(null);
console.error('Geocode was not successful for the following reason: ' + status);
}
},
);
}, [geocodingLibrary, address]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAddress(e.target.value);
};
return (
<div>
<input
type="text"
placeholder="Enter a city, state, or zip code"
value={address}
onChange={handleInputChange}
/>
{latLng && (
<p>
Latitude: {latLng.lat}, Longitude: {latLng.lng}
</p>
)}
{!latLng && address && <p>No results found.</p>}
</div>
);
};

View File

@@ -0,0 +1,25 @@
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
import { ReactElement} from 'react';
const LoadingSkeleton = (): ReactElement => {
return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
<Grid container sx={{ height: '100%' }}>
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
</Container>
)
}
export default LoadingSkeleton;

View File

@@ -0,0 +1,77 @@
import React, { useMemo } from 'react';
//import { GoogleMap, Marker, useLoadScript } from '@react-google-maps/api';
import { APIProvider, Map, AdvancedMarker, Pin } from '@vis.gl/react-google-maps';
import { Box, CircularProgress, Typography } from '@mui/material';
import LocationOnIcon from '@mui/icons-material/LocationOn';
interface MapComponentProps {
lat: number;
lng: number;
zoom?: number;
address?: string; // Optional for display
}
const libraries: ('places' | 'drawing' | 'geometry' | 'localContext' | 'visualization')[] = ['places']; // 'places' is a common library to load
const MapComponent: React.FC<MapComponentProps> = ({ lat, lng, zoom = 15, address }) => {
const latitude = Number(lat);
const longitude = Number(lng);
const defaultProps = {
center: { latitude, longitude },
zoom,
};
// Replace 'YOUR_Maps_API_KEY' with your actual API key
// const { isLoaded, loadError } = useLoadScript({
// id: 'dta_demo',
// googleMapsApiKey: 'AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w',//process.env.REACT_APP_Maps_API_KEY!, // Replace with your actual API key environment variable
// libraries: libraries,
// });
const center = useMemo(() => ({
lat: latitude,
lng: longitude,
}), [lat, lng]);
// if (loadError) {
// return (
// <Box display="flex" justifyContent="center" alignItems="center" height="100%">
// <Typography color="error">Error loading maps</Typography>
// </Box>
// );
// }
// if (!isLoaded) {
// return (
// <Box display="flex" justifyContent="center" alignItems="center" height="100%">
// <CircularProgress />
// </Box>
// );
// }
return (
<Box sx={{ height: 300, width: '100%', mt: 2, border: '1px solid #ccc' }}>
{lat && lng && center? (
<APIProvider apiKey={'AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w'}>
<Map
mapId={"dta-demo"}
defaultCenter={center}
zoom={defaultProps.zoom}
disableDefaultUI={true}
>
<AdvancedMarker position={center} />
</Map>
</APIProvider>
) : (
<Typography variant="body2" color="textSecondary" sx={{ p: 2 }}>
Map not available. Please ensure valid latitude and longitude.
</Typography>
)}
</Box>
);
};
export default MapComponent;

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import {
APIProvider,
Map,
AdvancedMarker,
Pin,
InfoWindow,
useMap,
useMapsLibrary,
MapCameraChangedEvent,
} from '@vis.gl/react-google-maps';
import { Box, Typography, useTheme, Button } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { PropertiesAPI } from 'types';
import DrawingManager from '../sections/dashboard/Home/Profile/DrawingManager';
import { cloneSourceShallow } from 'echarts/types/src/data/Source.js';
// Custom Marker component
interface MapMarkerProps {
property: PropertiesAPI;
onMarkerClick: (propertyId: number) => void;
onMarkerHover: (property: PropertiesAPI) => void;
onMarkerUnhover: () => void;
isListItemSelected: boolean;
centerMapToMarker: (position: google.maps.LatLngLiteral) => void;
}
const MapMarker: React.FC<MapMarkerProps> = ({
property,
onMarkerClick,
onMarkerHover,
onMarkerUnhover,
isListItemSelected,
centerMapToMarker,
}) => {
const theme = useTheme();
const [infowindowOpen, setInfowindowOpen] = useState(false);
const position = { lat: Number(property.latitude)!, lng: Number(property.longitude)! };
const handleMarkerClick = (e: any) => {
//e.stopPropagation();
setInfowindowOpen(true);
centerMapToMarker(position);
};
return (
<AdvancedMarker
position={position}
onClick={handleMarkerClick}
onMouseOver={() => {
onMarkerHover(property);
}}
onMouseOut={() => {
onMarkerUnhover();
}}
// You can use a custom pin or a regular one
// We'll use the Pin component for a simple custom look
// The isListItemSelected state will be handled by the parent
>
<Pin
background={isListItemSelected ? theme.palette.primary.main : theme.palette.secondary.main}
borderColor={isListItemSelected ? theme.palette.primary.dark : theme.palette.secondary.dark}
glyphColor={'white'}
/>
{infowindowOpen && (
<InfoWindow position={position} onCloseClick={() => setInfowindowOpen(false)}>
<Box sx={{ p: 1, minWidth: 150 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{property.address}
</Typography>
<Typography variant="caption" color="text.secondary">
{property.city}, {property.state}
</Typography>
<Button size="small" onClick={() => onMarkerClick(property.id)} sx={{ mt: 1 }}>
View Details
</Button>
</Box>
</InfoWindow>
)}
</AdvancedMarker>
);
};
// Main Map Component
interface MapProps {
center: google.maps.LatLngLiteral;
zoom: number;
properties: PropertiesAPI[];
selectedPropertyId: number | null;
onBoundsChanged: (bounds: any) => void;
onBoxDrawn: (bounds: any) => void;
onMarkerClick: (propertyId: number) => void;
onMarkerHover: (property: PropertiesAPI) => void;
onMarkerUnhover: () => void;
}
const MapSerachComponent: React.FC<MapProps> = ({
center,
zoom,
properties,
selectedPropertyId,
onBoundsChanged,
onBoxDrawn,
onMarkerClick,
onMarkerHover,
onMarkerUnhover,
}) => {
const navigate = useNavigate();
const [map, setMap] = useState<google.maps.Map | null>(null);
const onMapChange = (event: MapCameraChangedEvent) => {
const bounds = event.bounds;
onBoundsChanged({
ne: bounds.northEast,
sw: bounds.southWest,
});
};
const handleMarkerClick = (propertyId: number) => {
console.log('clicked a marker');
navigate(`/property/${propertyId}`);
onMarkerClick(propertyId);
};
const centerMapToMarker = (position: google.maps.LatLngLiteral) => {
map?.setCenter(position);
map?.setZoom(15);
};
console.log(properties);
return (
<Box sx={{ height: '70vh', width: '100%', position: 'relative' }}>
<APIProvider apiKey={'AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w'}>
<Map
defaultCenter={center}
defaultZoom={zoom}
//onCameraChanged={onMapChange}
mapId={'MapSearchComponent'} // Replace with your Map ID from Google Cloud Console
onLoad={setMap}
disableDefaultUI={true}
>
{properties.map(
(property) =>
property.latitude &&
property.longitude && (
<MapMarker
key={property.id}
property={property}
onMarkerClick={handleMarkerClick}
onMarkerHover={onMarkerHover}
onMarkerUnhover={onMarkerUnhover}
isListItemSelected={selectedPropertyId === property.id}
centerMapToMarker={centerMapToMarker}
/>
),
)}
{/* <DrawingManager onBoxDrawn={onBoxDrawn} /> */}
</Map>
</APIProvider>
</Box>
);
};
export default MapSerachComponent;

View File

@@ -0,0 +1,118 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Stack,
} from '@mui/material';
import { axiosInstance } from '../../../../../axiosApi';
import axios, { AxiosResponse } from 'axios';
import { BidAPI, PropertiesAPI } from 'types';
interface AddBidDialogProps {
open: boolean;
onClose: () => void;
properties: PropertiesAPI[];
onBidAdded: () => void;
}
export const AddBidDialog: React.FC<AddBidDialogProps> = ({
open,
onClose,
properties,
onBidAdded,
}) => {
const [property, setProperty] = useState('');
const [description, setDescription] = useState('');
const [bidType, setBidType] = useState('');
const [location, setLocation] = useState('');
const [images, setImages] = useState<File[]>([]);
const handleSubmit = async () => {
const formData = new FormData();
formData.append('property', property);
formData.append('description', description);
formData.append('bid_type', bidType);
formData.append('location', location);
images.forEach((image) => formData.append('images', image));
try {
const { data }: AxiosResponse<BidAPI> = await axiosInstance.post('/bids/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
onBidAdded();
onClose();
} catch (error) {
console.error('Failed to create bid', error);
}
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Create New Bid</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}>
<FormControl fullWidth>
<InputLabel>Property</InputLabel>
<Select value={property} onChange={(e) => setProperty(e.target.value as string)}>
{properties.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.address}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Description"
multiline
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Bid Type</InputLabel>
<Select value={bidType} onChange={(e) => setBidType(e.target.value as string)}>
<MenuItem value="electrical">Electrical</MenuItem>
<MenuItem value="plumbing">Plumbing</MenuItem>
<MenuItem value="carpentry">Carpentry</MenuItem>
<MenuItem value="general_contractor">General Contractor</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select value={location} onChange={(e) => setLocation(e.target.value as string)}>
<MenuItem value="living_room">Living Room</MenuItem>
<MenuItem value="basement">Basement</MenuItem>
<MenuItem value="kitchen">Kitchen</MenuItem>
<MenuItem value="bathroom">Bathroom</MenuItem>
<MenuItem value="bedroom">Bedroom</MenuItem>
<MenuItem value="outside">Outside</MenuItem>
</Select>
</FormControl>
<Button variant="contained" component="label">
Upload Pictures
<input
type="file"
hidden
multiple
onChange={(e) => e.target.files && setImages(Array.from(e.target.files))}
/>
</Button>
<Button variant="contained" onClick={handleSubmit}>
Submit Bid
</Button>
</Stack>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Card, CardContent, Typography, Button, Box, Grid } from '@mui/material';
import axios from 'axios';
import { axiosInstance } from '../../../../../axiosApi';
import { BidAPI } from 'types';
interface BidCardProps {
bid: BidAPI;
onDelete: (bidId: number) => void;
isOwner: boolean;
}
export const BidCard: React.FC<BidCardProps> = ({ bid, onDelete, isOwner }) => {
const handleSelectResponse = async (responseId: number) => {
try {
await axiosInstance.post(`/bids/${bid.id}/select_response/`, { response_id: responseId });
// You might want to refresh the parent component's state here
window.location.reload();
} catch (error) {
console.error('Failed to select response', error);
}
};
return (
<Card variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6">Bid for {bid.bid_type}</Typography>
<Typography color="text.secondary">Location: {bid.location}</Typography>
<Typography variant="body2" paragraph>
{bid.description}
</Typography>
{bid.images.length > 0 && (
<Box sx={{ display: 'flex', overflowX: 'auto', mb: 2 }}>
{bid.images.map((image) => (
<img
key={image.id}
src={image.image_url}
alt="Bid"
style={{ height: 100, marginRight: 8 }}
/>
))}
</Box>
)}
{isOwner && (
<Button variant="outlined" color="error" onClick={() => onDelete(bid.id)}>
Delete Bid
</Button>
)}
{/* Responses Section */}
{bid.responses.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Responses:</Typography>
{bid.responses.map((response) => (
<Card key={response.id} variant="outlined" sx={{ mt: 1, p: 1 }}>
<Typography variant="body2">
<strong>Vendor</strong>: {response.vendor.business_name}
</Typography>
<Typography variant="body2">
<strong>Price</strong>: ${response.price}
</Typography>
<Typography variant="body2">
<strong>Description</strong>: {response.description}
</Typography>
<Typography variant="body2">
<strong>Status</strong>: {response.status}
</Typography>
{isOwner && response.status !== 'selected' && (
<Button
variant="contained"
size="small"
onClick={() => handleSelectResponse(response.id)}
>
Select Response
</Button>
)}
{isOwner && response.status === 'selected' && (
<Button variant="contained" size="small" disabled>
Selected
</Button>
)}
</Card>
))}
</Box>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
TextField,
Box,
} from '@mui/material';
import { axiosInstance } from '../../../../../axiosApi';
import axios from 'axios';
import { BidAPI } from 'types';
interface VendorBidCardProps {
bid: BidAPI;
onResponseSubmitted: () => void;
}
export const VendorBidCard: React.FC<VendorBidCardProps> = ({ bid, onResponseSubmitted }) => {
const [openResponseDialog, setOpenResponseDialog] = useState(false);
const [responseDescription, setResponseDescription] = useState('');
const [responsePrice, setResponsePrice] = useState('');
const myResponse = bid.responses.find((res) => res.vendor.user.id === 'current_user_id'); // Replace with actual user ID logic
const handleSubmitResponse = async () => {
try {
await axiosInstance.post('/bid-responses/', {
bid: bid.id,
description: responseDescription,
price: responsePrice,
});
onResponseSubmitted();
setOpenResponseDialog(false);
} catch (error) {
console.error('Failed to submit response', error);
}
};
return (
<Card variant="outlined">
<CardContent>
<Typography variant="h6">Bid for {bid.bid_type}</Typography>
<Typography color="text.secondary">Location: {bid.location}</Typography>
<Typography variant="body2" paragraph>
{bid.description}
</Typography>
{bid.images.length > 0 && (
<Box sx={{ display: 'flex', overflowX: 'auto', mb: 2 }}>
{bid.images.map((image) => (
<img
key={image.id}
src={image.image}
alt="Bid"
style={{ height: 100, marginRight: 8 }}
/>
))}
</Box>
)}
{myResponse ? (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Your Response:</Typography>
<Typography variant="body2">
<strong>Price</strong>: ${myResponse.price}
</Typography>
<Typography variant="body2">
<strong>Description</strong>: {myResponse.description}
</Typography>
<Typography variant="body2">
<strong>Status</strong>: {myResponse.status}
</Typography>
</Box>
) : (
<Button variant="contained" onClick={() => setOpenResponseDialog(true)}>
Submit Response
</Button>
)}
</CardContent>
<Dialog open={openResponseDialog} onClose={() => setOpenResponseDialog(false)}>
<DialogTitle>Submit Response</DialogTitle>
<DialogContent>
<TextField
label="Price"
type="number"
value={responsePrice}
onChange={(e) => setResponsePrice(e.target.value)}
fullWidth
margin="normal"
/>
<TextField
label="Description"
multiline
rows={4}
value={responseDescription}
onChange={(e) => setResponseDescription(e.target.value)}
fullWidth
margin="normal"
/>
<Button variant="contained" onClick={handleSubmitResponse}>
Submit
</Button>
</DialogContent>
</Dialog>
</Card>
);
};

View File

@@ -0,0 +1,42 @@
import React, { useState, useEffect } from 'react';
import { Container, Typography, Grid, Card, CardContent } from '@mui/material';
import axios, { AxiosResponse } from 'axios';
import { axiosInstance } from '../../../../../axiosApi';
import { BidAPI } from 'types';
import { VendorBidCard } from './VendorBidCard';
const VendorBidsPage: React.FC = () => {
const [bids, setBids] = useState<BidAPI[]>([]);
useEffect(() => {
fetchBids();
}, []);
const fetchBids = async () => {
try {
// Endpoint to get all bids a vendor can see
const { data: bidData }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
setBids(bidData);
} catch (error) {
console.error('Failed to fetch bids', error);
}
};
return (
<Container>
<Typography variant="h4" gutterBottom color="background.paper">
Available Bids
</Typography>
<Grid container spacing={3} sx={{ mt: 3 }}>
{bids.map((bid) => (
<Grid item xs={12} md={6} lg={4} key={bid.id}>
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
</Grid>
))}
</Grid>
</Container>
);
};
export default VendorBidsPage;

View File

@@ -0,0 +1,175 @@
// src/pages/AttorneyDashboardPage.tsx
import React, { ReactElement, useEffect, useState } from 'react';
import {
Container,
Typography,
Box,
Grid,
Card,
CardContent,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
Button,
Alert,
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import EventIcon from '@mui/icons-material/Event';
import DescriptionIcon from '@mui/icons-material/Description';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import { DashboardProps } from 'pages/home/Dashboard';
import { useNavigate } from 'react-router-dom';
import { AttorneyAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi';
import DashboardLoading from './DashboardLoading';
import DashboardErrorPage from './DashboardErrorPage';
import { AxiosResponse } from 'axios';
// Mock Data for the Attorney Dashboard
interface AttorneyCase {
id: number;
title: string;
status: 'active' | 'closed' | 'urgent';
deadline: string;
}
const mockAttorneyCases: AttorneyCase[] = [
{ id: 1, title: 'Closing for 123 Main St', status: 'urgent', deadline: 'August 15, 2025' },
{ id: 2, title: 'Contract Review - 456 Oak Ave', status: 'active', deadline: 'August 20, 2025' },
{ id: 3, title: 'Title Search - 789 Pine Ln', status: 'active', deadline: 'September 1, 2025' },
];
const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
const [attorney, setAttorney] = useState<AttorneyAPI | null>(null);
const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate();
useEffect(() => {
const fetchAttorney = async () => {
try {
const { data }: AxiosResponse<AttorneyAPI[]> = await axiosInstance.get('/attorney/');
if (data.length > 0) {
setAttorney(data[0]);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
fetchAttorney();
}, []);
if (loadingData) {
return <DashboardLoading />;
}
if (attorney === null) {
return <DashboardErrorPage />;
}
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
Attorney Dashboard
</Typography>
{!account.profile_created && (
<Alert severity="warning" sx={{ mb: 2 }}>
Please set up your <a href="/profile">profile</a>
</Alert>
)}
<Grid container spacing={3}>
{/* Active Cases Card */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<FolderIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Active Cases
</Typography>
</Box>
<List>
{mockAttorneyCases.map((c) => (
<React.Fragment key={c.id}>
<ListItem>
<ListItemIcon>
{c.status === 'urgent' ? (
<WarningIcon color="warning" />
) : (
<CheckCircleIcon color="success" />
)}
</ListItemIcon>
<ListItemText primary={c.title} secondary={`Deadline: ${c.deadline}`} />
</ListItem>
<Divider component="li" />
</React.Fragment>
))}
</List>
<Button fullWidth sx={{ mt: 2 }}>
View All Cases
</Button>
</CardContent>
</Card>
</Grid>
{/* Upcoming Deadlines Card */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<EventIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Upcoming Deadlines
</Typography>
</Box>
<List>
{mockAttorneyCases
.filter((c) => c.status === 'urgent')
.map((c) => (
<ListItem key={c.id}>
<ListItemIcon>
<WarningIcon color="warning" />
</ListItemIcon>
<ListItemText primary={c.title} secondary={`Due by: ${c.deadline}`} />
</ListItem>
))}
</List>
<Button fullWidth sx={{ mt: 2 }}>
View Calendar
</Button>
</CardContent>
</Card>
</Grid>
{/* Documents to Review Card */}
<Grid item xs={12}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<DescriptionIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Documents Requiring Action
</Typography>
</Box>
<List dense>
<ListItem>
<ListItemText
primary="Contract for 123 Main St"
secondary="Needs your signature"
/>
</ListItem>
<ListItem>
<ListItemText
primary="Title Report for 456 Oak Ave"
secondary="Awaiting your review"
/>
</ListItem>
</List>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
);
};
export default AttorneyDashboard;

View File

@@ -0,0 +1,41 @@
import React, { ReactElement } from 'react';
import { Container, Box, Typography, Button } from '@mui/material';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import RefreshIcon from '@mui/icons-material/Refresh';
interface DashboardErrorPageProps {
errorMessage?: string;
onRetry?: () => void;
}
const DashboardErrorPage = ({
errorMessage = "We couldn't load your dashboard data.",
onRetry,
}: DashboardErrorPageProps): ReactElement => {
return (
<Container maxWidth="md" sx={{ mt: 8, mb: 4, textAlign: 'center' }}>
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="60vh" // Give it some vertical height
>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 3 }} />
<Typography variant="h5" color="text.secondary" sx={{ mb: 1 }}>
Oops! Something went wrong.
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{errorMessage} Please try again.
</Typography>
{onRetry && (
<Button variant="contained" color="primary" startIcon={<RefreshIcon />} onClick={onRetry}>
Retry
</Button>
)}
</Box>
</Container>
);
};
export default DashboardErrorPage;

View File

@@ -0,0 +1,26 @@
import React, { ReactElement } from 'react';
import { Container, Box, CircularProgress, Typography } from '@mui/material';
const DashboardLoading = (): ReactElement => {
return (
<Container maxWidth="md" sx={{ mt: 8, mb: 4, textAlign: 'center' }}>
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="60vh" // Give it some vertical height
>
<CircularProgress size={60} sx={{ mb: 3 }} />
<Typography variant="h5" color="text.secondary">
Loading your dashboard...
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mt: 1 }}>
Please wait while we fetch the latest data.
</Typography>
</Box>
</Container>
);
};
export default DashboardLoading;

View File

@@ -0,0 +1,55 @@
import { ReactElement } from 'react';
import { Card, CardContent, CardMedia, Stack, Typography } from '@mui/material';
import { PropertiesAPI } from 'types';
import Image from 'components/base/Image';
import { currencyFormat } from 'helpers/format-functions';
import MarkUnreadChatAltIcon from '@mui/icons-material/MarkUnreadChatAlt';
export const NotificationInfoCard = () => {
return(
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
<Card
sx={(theme) => ({
boxShadow: theme.shadows[4],
width: 1,
height: 'auto',
})}
>
<CardMedia
sx={{
maxWidth: 70,
maxHeight: 70,
}}
>
<MarkUnreadChatAltIcon />
</CardMedia>
<CardContent
sx={{
flex: '1 1 auto',
padding: 0,
':last-child': {
paddingBottom: 0,
},
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
Unread Notifications
</Typography>
</Stack>
<Typography variant="body1" component="p" color="text.secondary">
Messages
</Typography>
<Typography variant="body1" component="p" color="text.secondary">
Offers
</Typography>
</CardContent>
</Card>
</Stack>
)
}
export default NotificationInfoCard;

View File

@@ -0,0 +1,302 @@
import { AxiosResponse } from 'axios';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, UserAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi';
import Grid from '@mui/material/Unstable_Grid2';
import {
Alert,
Button,
Card,
CardActionArea,
CardContent,
Divider,
Stack,
Typography,
} from '@mui/material';
import { drawerWidth } from 'layouts/main-layout';
import { ProperyInfoCards } from '../Property/PropertyInfo';
import { useNavigate } from 'react-router-dom';
import HouseIcon from '@mui/icons-material/House';
import VisibilityIcon from '@mui/icons-material/Visibility';
import FavoriteIcon from '@mui/icons-material/Favorite';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import { EducationInfoCards } from '../Education/EducationInfo';
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { GridColDef } from '@mui/x-data-grid';
import PropertyDetailCard from '../Property/PropertyDetailCard';
import { DashboardProps } from 'pages/home/Dashboard';
interface Row {
id: number;
}
const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
const [numBids, setNumBids] = useState<Number>(0);
const [numOffers, setNumOffers] = useState<Number>(0);
const navigate = useNavigate();
useEffect(() => {
const fetchProperties = async () => {
try {
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
if (data.length > 0) {
setProperties(data);
}
} catch (error) {
console.log(error);
}
};
const fetchOffers = async () => {
try {
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/offers/');
if (data.length > 0) {
setNumOffers(data.length);
}
} catch (error) {
console.log(error);
}
};
const fetchBids = async () => {
try {
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/bids/');
if (data.length > 0) {
setNumBids(data.length);
}
} catch (error) {
console.log(error);
}
};
fetchProperties();
fetchOffers();
fetchBids();
}, []);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
console.log('handle save. IMPLEMENT ME');
};
const handleDeleteProperty = (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
};
const documentColumns: GridColDef[] = [
{
field: 'id',
headerName: 'ID',
},
{
field: 'title',
headerName: 'Title',
flex: 1,
},
{
field: 'action',
headerName: 'Action',
flex: 0,
renderCell: (params: GridRenderCellParams<Row, number>) => {
return (
<Button variant="contained" component="label">
View
</Button>
);
},
},
];
const DocumentRows = [
{
id: 1,
title: 'Offer',
},
{
id: 2,
title: 'Disclousre Form',
},
];
const numViews = properties.reduce((accum, currProperty) => {
return accum + currProperty.views;
}, 0);
const numSaves = properties.reduce((accum, currProperty) => {
return accum + currProperty.saves;
}, 0);
return (
<Grid
container
component="main"
columns={12}
spacing={3.75}
flexGrow={1}
pt={4.375}
pr={1.875}
pb={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
pl: { xs: 3.75, lg: 0 },
}}
>
{/* quick states */}
{!account.profile_created && (
<Grid xs={12} key="profile-setup">
<Alert severity="warning" sx={{ mb: 2 }}>
Please set up your <a href="/profile">profile</a>
</Alert>
</Grid>
)}
<Grid xs={12} sm={6} md={4} lg={3} key="active-listing-card">
<Card sx={{ display: 'flex' }} onClick={() => navigate('/profile')}>
<CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center">
<HouseIcon />
<Typography variant="button">{properties.length}</Typography>
<Typography variant="caption">Active Listings</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid xs={12} sm={6} md={4} lg={3} key="num-views-card">
<Card sx={{ display: 'flex' }}>
<CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center">
<VisibilityIcon />
<Typography>{numViews}</Typography>
<Typography variant="caption">Total Views</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid xs={12} sm={6} md={4} lg={3} key="total-saves-card">
<Card sx={{ display: 'flex' }}>
<CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center">
<FavoriteIcon />
<Typography>{numSaves}</Typography>
<Typography variant="caption">Total Saves</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid xs={12} sm={6} md={4} lg={3} key="num-offers-card">
<Card sx={{ display: 'flex' }} onClick={() => navigate('/offers')}>
<CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center">
<RequestQuoteIcon />
<Typography variant="h6">{numOffers}</Typography>
<Typography variant="caption">Total Offers</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid xs={12} sm={6} md={4} lg={3} key="num-bids-card">
<Card sx={{ display: 'flex' }} onClick={() => navigate('/bids')}>
<CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center">
<RequestQuoteIcon />
<Typography variant="h6">{numBids}</Typography>
<Typography variant="caption">Total Bids</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
{/* Properties */}
{properties.map((item) => (
<Grid xs={12} key={item.id}>
<PropertyDetailCard
property={item}
isPublicPage={false}
onSave={handleSaveProperty}
isOwnerView={true}
onDelete={handleDeleteProperty}
/>
{/* <ProperyInfoCards property={item} /> */}
</Grid>
))}
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid xs={12} md={6}>
<Card sx={{ display: 'flex' }}>
<Stack direction="column">
<Typography variant="h4">Documents Requiring Attention</Typography>
<Typography variant="caption">something</Typography>
</Stack>
<CardContent sx={{ flexGrow: 1 }}>
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
</CardContent>
</Card>
</Grid>
<Grid xs={12} md={6}>
<Card>
<Stack direction="column">
<Typography variant="h4">Video Progress</Typography>
<Typography variant="caption">
Complete our FSBO training to maximize your sale potential
</Typography>
</Stack>
<CardContent>
<EducationInfoCards />
</CardContent>
</Card>
</Grid>
<Grid xs={12} md={4}>
<Card>
<Stack direction="column">
<Typography variant="h4">Upgrade your account</Typography>
<Typography variant="caption">
Unlock premium features to get more features and sell faster
</Typography>
</Stack>
<CardActionArea>
<Button variant="contained" component="label">
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
</Grid>
{/* <Grid xs={12} md={4}>
<NotificationInfoCard />
</Grid>
<Grid xs={12} md={8}>
<EducationInfoCards />
</Grid>
<Grid xs={12}>
<SaleInfoCards />
</Grid>
<Grid xs={12} md={8}>
<Revenue />
</Grid>
<Grid xs={12} md={4}>
<WebsiteVisitors />
</Grid>
<Grid xs={12} lg={8}>
<TopSellingProduct />
</Grid>
<Grid xs={12} lg={4}>
<Stack
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
gap={3.75}
height={1}
width={1}
>
<NewCustomers />
<BuyersProfile />
</Stack>
</Grid> */}
</Grid>
);
};
export default PropertyOwnerDashboard;

View File

@@ -0,0 +1,169 @@
// src/pages/RealEstateAgentDashboardPage.tsx
import React, { ReactElement } from 'react';
import {
Container,
Typography,
Box,
Grid,
Card,
CardContent,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
Button,
} from '@mui/material';
import HomeIcon from '@mui/icons-material/Home';
import GavelIcon from '@mui/icons-material/Gavel';
import EventAvailableIcon from '@mui/icons-material/EventAvailable';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import { DashboardProps } from 'pages/home/Dashboard';
// Mock Data for the Real Estate Agent Dashboard
interface AgentListing {
id: number;
address: string;
status: 'active' | 'pending';
offers: number;
views: number;
}
interface AgentOffer {
id: number;
property_address: string;
offer_amount: number;
offer_date: string;
}
const mockAgentListings: AgentListing[] = [
{ id: 1, address: '123 Main St, Anytown', status: 'active', offers: 3, views: 250 },
{ id: 2, address: '456 Oak Ave, Anytown', status: 'pending', offers: 1, views: 180 },
{ id: 3, address: '789 Pine Ln, Othertown', status: 'active', offers: 0, views: 50 },
];
const mockAgentOffers: AgentOffer[] = [
{
id: 1,
property_address: '123 Main St, Anytown',
offer_amount: 510000,
offer_date: 'August 5, 2025',
},
];
const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement => {
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Agent Dashboard
</Typography>
<Grid container spacing={3}>
{/* Listings Summary Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<HomeIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
My Listings
</Typography>
</Box>
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>
{mockAgentListings.length}
</Typography>
<Typography variant="subtitle1">Total Active Listings</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="body1">
Total Offers: {mockAgentListings.reduce((sum, l) => sum + l.offers, 0)}
</Typography>
</CardContent>
</Card>
</Grid>
{/* New Offers Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
New Offers
</Typography>
</Box>
{mockAgentOffers.length > 0 ? (
<List dense>
{mockAgentOffers.map((offer) => (
<ListItem key={offer.id}>
<ListItemText
primary={`$${offer.offer_amount.toLocaleString()}`}
secondary={`On ${offer.property_address}`}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body1">No new offers at this time.</Typography>
)}
<Button fullWidth sx={{ mt: 2 }}>
View All Offers
</Button>
</CardContent>
</Card>
</Grid>
{/* Upcoming Showings Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<EventAvailableIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Upcoming Appointments
</Typography>
</Box>
<List dense>
<ListItem>
<ListItemIcon>
<PersonAddIcon />
</ListItemIcon>
<ListItemText
primary="Showing for John Doe"
secondary="123 Main St - 2:00 PM today"
/>
</ListItem>
<ListItem>
<ListItemIcon>
<PersonAddIcon />
</ListItemIcon>
<ListItemText primary="Open House" secondary="789 Pine Ln - Sunday at 1:00 PM" />
</ListItem>
</List>
<Button fullWidth sx={{ mt: 2 }}>
View My Calendar
</Button>
</CardContent>
</Card>
</Grid>
{/* Example of other cards */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
Listing Performance
</Typography>
{/* A chart or a list of top-performing listings could go here */}
<Typography variant="body1">
123 Main St is your top-performing listing with 250 views.
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
);
};
export default RealEstateAgentDashboard;

View File

@@ -0,0 +1,292 @@
// src/pages/VendorDashboardPage.tsx
import React, { ReactElement, useEffect, useState } from 'react';
import {
Container,
Typography,
Box,
Grid,
Card,
CardContent,
Divider,
Chip,
Alert,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import VisibilityIcon from '@mui/icons-material/Visibility';
import GavelIcon from '@mui/icons-material/Gavel';
import ChatBubbleIcon from '@mui/icons-material/ChatBubble';
import NotificationsIcon from '@mui/icons-material/Notifications';
import PaidIcon from '@mui/icons-material/Paid';
import { DashboardProps } from 'pages/home/Dashboard';
import { BidAPI, ConverationAPI, VendorAPI, VendorItem } from 'types';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../../../../axiosApi';
import DashboardLoading from './DashboardLoading';
import DashboardErrorPage from './DashboardErrorPage';
import VendorDetail from '../Vendor/VendorDetail';
// Mock Data for the Vendor Dashboard
interface VendorDashboardData {
views: {
total: number;
last_30_days: number;
};
bids: {
total: number;
responded_to: number;
new_bids: number;
selected_for: number;
};
conversations: {
unread: number;
};
}
const mockVendorData: VendorDashboardData = {
views: {
total: 5432,
last_30_days: 215,
},
bids: {
total: 45,
responded_to: 38,
new_bids: 7,
selected_for: 12,
},
conversations: {
unread: 3,
},
};
const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
const [vendor, setVendor] = useState<VendorAPI | null>(null);
const [loadingData, setLoadingData] = useState<boolean>(true);
// bid data
const [numBids, setNumBids] = useState<Number>(0);
const [numBidResponses, setNumBidResponses] = useState<Number>(0);
const [numBidsSelected, setNumBidsSelected] = useState<Number>(0);
const [newBids, setNewBids] = useState<Number>(0);
const [numConverstaions, setNumConversations] = useState<Number>(0);
const navigate = useNavigate();
useEffect(() => {
const fetchVendor = async () => {
try {
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
if (data.length > 0) {
setVendor(data[0]);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
const fetchBids = async () => {
try {
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
if (data.length > 0) {
setNumBids(data.length);
let numBidResponsesFound: number = 0;
let numBidsSelectedFound: number = 0;
let newBidsFound: number = 0;
data.map((item) => {
let foundNewBid: boolean = true;
item.responses.map((response) => {
if (response.vendor.user.id === account.id) {
numBidResponsesFound = numBidResponsesFound + 1;
foundNewBid = false;
}
if (response.vendor.user.id === account.id && response.status === 'selected') {
numBidsSelectedFound = numBidsSelectedFound + 1;
}
});
newBidsFound = foundNewBid ? newBidsFound + 1 : newBidsFound;
});
setNumBidResponses(numBidResponsesFound);
setNumBidsSelected(numBidsSelectedFound);
setNewBids(newBidsFound);
}
} catch (error) {
console.log(error);
}
};
const fetchConversations = async () => {
try {
const { data }: AxiosResponse<ConverationAPI[]> =
await axiosInstance.get('/conversations/');
if (data.length > 0) {
setNumConversations(data.length);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
fetchVendor();
fetchBids();
fetchConversations();
}, []);
if (loadingData) {
return <DashboardLoading />;
}
if (vendor === null) {
return <DashboardErrorPage />;
}
const vendorItem: VendorItem = {
contactPerson: vendor.user.first_name + ' ' + vendor.user.last_name,
name: vendor.business_name,
description: vendor.description,
phone: vendor.phone_number,
email: vendor.user.email,
address: vendor.address,
vendorImageUrl: '',
rating: 5,
servicesOffered: vendor.services,
serviceAreas: vendor.service_areas,
categoryId: vendor.business_type,
latitude: Number(vendor.latitude),
longitude: Number(vendor.longitude),
views: vendor.views,
};
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
Vendor Dashboard
</Typography>
{!account.profile_created && (
<Alert severity="warning" sx={{ mb: 2 }}>
Please set up your <a href="/profile">profile</a>
</Alert>
)}
<Grid container spacing={3}>
{/* Views Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<VisibilityIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Profile Views
</Typography>
</Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
{vendor.views.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Views
</Typography>
<Divider sx={{ my: 1 }} />
<Typography variant="body1">
{mockVendorData.views.last_30_days} in the last 30 days
</Typography>
</CardContent>
</Card>
</Grid>
{/* Bids Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Bids & Opportunities
</Typography>
</Box>
<Box display="flex" justifyContent="space-around" textAlign="center">
<Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
{numBids}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Bids
</Typography>
</Box>
<Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
<Chip label={newBids} color="error" size="small" />
</Typography>
<Typography variant="caption" color="text.secondary">
New Bids
</Typography>
</Box>
</Box>
<Divider sx={{ my: 1 }} />
<Box display="flex" justifyContent="space-around" textAlign="center">
<Box>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
{numBidResponses}
</Typography>
<Typography variant="caption" color="text.secondary">
Responded To
</Typography>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
{numBidsSelected}
</Typography>
<Typography variant="caption" color="text.secondary">
Selected For
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* Conversations Card */}
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<ChatBubbleIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Conversations
</Typography>
</Box>
<Box textAlign="center" mb={1}>
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{numConverstaions}
</Typography>
<Typography variant="subtitle1">Unread Messages</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Typography variant="body2" color="text.secondary" textAlign="center">
Stay on top of your messages to win more bids!
</Typography>
</CardContent>
</Card>
</Grid>
{/* Example of other cards that could be useful */}
{/*<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<PaidIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Recent Payments
</Typography>
</Box>
<Typography variant="h6">You have no new payments.</Typography>
</CardContent>
</Card>
</Grid>*/}
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid item xs={12} md={12}>
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
</Grid>
</Grid>
</Container>
);
};
export default VendorDashboard;

View File

@@ -0,0 +1,24 @@
// src/components/CategoryGrid.tsx
import React from 'react';
import { Grid } from '@mui/material';
import CategoryCard from './VideoCategory';
import { VideoCategory } from 'pages/Education/Education';
interface CategoryGridProps {
categories: VideoCategory[];
onSelectCategory: (categoryName: string) => void;
}
const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategory }) => {
return (
<Grid container spacing={3}>
{categories.map((category) => (
<Grid item xs={12} sm={6} md={4} key={category.name}>
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
</Grid>
))}
</Grid>
);
};
export default CategoryGrid;

View File

@@ -108,15 +108,6 @@ const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
progress: 100,
status: "COMPLETED",
},
{
id: 6,
title: "The Ultimate Home Staging Checklist for FSBO Sellers",

View File

@@ -0,0 +1,75 @@
import { Box, Card, CardContent, Divider, LinearProgress, Stack, Typography } from '@mui/material';
type EducationVideoColumnCardProps = {
title: string;
category: string;
progressValue: number;
}
type EducationVideoCategoryColumnCardProps = {
category: string;
videos: Array<EducationVideoColumnCardProps>;
}
const EducationVideoCategoryColumnCard = ({category, videos}:EducationVideoCategoryColumnCardProps ) => {
return(
<Stack direction='column'>
<Typography variant='h4'>
{category}
</Typography>
{videos.map(({title, category, progressValue}) => <EducationVideoColumnCard title={title} category={category} progressValue={progressValue} />)}
</Stack>
)
}
const EducationVideoColumnCard = ({title, category, progressValue}: EducationVideoColumnCardProps ) => {
return (
<Card
sx={{
mb:1,
borderStyle:'hidden',
borderWidth: 1,
boxShadow: 1
}}
>
<CardContent
sx={{padding: '1px', paddingBottom: '1px'}}
>
<Typography variant="caption" component="div">
{title}
</Typography>
<LinearProgress variant="determinate" value={progressValue} />
</CardContent>
</Card>
)
// return (
// <Stack direction='column'>
// <Typography variant='h6' color="text.primary">
// {title}
// </Typography>
// <Typography variant='caption' color="text.primary">
// {category}
// </Typography>
// <LinearProgress variant="determinate" value={progressValue} />
// </Stack>
// )
}
type EducationTableProps = {
videosByCategories: {
category: string;
videos: {
id: number;
title: string;
progressValue: number;
status: string;
}[];
}[]
}
export default EducationVideoCategoryColumnCard;

View File

@@ -0,0 +1,28 @@
import { Box, Typography } from '@mui/material';
import { ReactElement } from 'react';
type EducationVideoPlayerProps = {
videoUrl: string | null;
videoTitle: string | null;
}
const EducationVideoPlayer = ({videoUrl, videoTitle}: EducationVideoPlayerProps) => {
if (videoUrl){
return (
<p>play video here</p>
)
}else{
return (
<Box sx={{display:'flex', justifyContent: 'center', alignItems: 'center', }}>
<Typography variant='h5' color="text.secondary">
Please select a vido from the list
</Typography>
</Box>
)
}
}
export default EducationVideoPlayer;

View File

@@ -0,0 +1,45 @@
// src/components/VideoApp/VideoCategoryCard.tsx
import React from 'react';
import { Card, CardContent, CardMedia, Typography, Button, LinearProgress, Box } from '@mui/material';
import { VideoCategory } from 'types';
interface VideoCategoryCardProps {
category: VideoCategory;
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
}
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
console.log(category)
return (
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia
component="img"
height="140"
image={category.imageUrl}
alt={category.name}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="div">
{category.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{category.description}
</Typography>
<Box sx={{ width: '100%', mt: 2 }}>
<Typography variant="body2" color="text.secondary">{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography>
<LinearProgress variant="determinate" value={category.categoryProgress} sx={{ height: 8, borderRadius: 5, mt: 1 }} />
<Typography variant="caption" display="block" align="right">{`${category.categoryProgress.toFixed(0)}%`}</Typography>
</Box>
</CardContent>
<Box sx={{ p: 2, pt: 0 }}>
<Button size="small" onClick={() => onSelectCategory(category.id)}>
View Videos
</Button>
</Box>
</Card>
);
};
export default VideoCategoryCard;

View File

@@ -0,0 +1,45 @@
// src/components/VideoApp/VideoListItem.tsx
import React from 'react';
import { ListItem, ListItemText, Typography, LinearProgress, Box } from '@mui/material';
import { VideoItem } from 'types';
interface VideoListItemProps {
video: VideoItem;
isSelected: boolean;
onSelect: (videoId: string) => void;
}
const VideoListItem: React.FC<VideoListItemProps> = ({ video, isSelected, onSelect }) => {
const percentage: number = Math.round(video.progress/video.duration*100)
return (
<ListItem
button
selected={isSelected}
onClick={() => {
console.log('selecting new video')
onSelect(video.id)
}}
sx={{ borderBottom: '1px solid #eee' }}
>
<ListItemText
primary={video.name}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{video.description}
</Typography>
<LinearProgress variant="determinate" value={percentage} sx={{ height: 5, borderRadius: 5, mt: 0.5 }} />
<Typography variant="caption" display="block" align="right">
{`${percentage.toFixed(0)}% ${video.status === 'completed' ? '(Completed)' : ''}`}
</Typography>
</Box>
}
/>
</ListItem>
);
};
export default VideoListItem;

View File

@@ -0,0 +1,290 @@
// src/components/VideoApp/VideoPlayer.tsx
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
import { AccountContext } from 'contexts/AccountContext';
import {axiosInstance} from '../../../../../axiosApi';
import { VideoItem, VideoProgressAPI } from 'types';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import ReplayIcon from '@mui/icons-material/Replay';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import ExitFullscreenIcon from '@mui/icons-material/FullscreenExit';
interface VideoProgress {
id?: number; // Optional as it might not exist for new progress entries
user?: number; // Assuming user ID is a number
video?: number; // Video ID
current_time?: number; // Current playback time in seconds
completed?: boolean;
last_watched?: string; // Optional, set by backend
progress: number;
}
interface VideoPlayerProps {
video: VideoItem;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const {account, accountLoading} = useContext(AccountContext);
if (!video || accountLoading) {
return (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h6" color="text.secondary">No video selected</Typography>
</Paper>
);
}
//
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [isFullScreen, setIsFullScreen] = useState(false);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Function to save progress to the backend
const saveProgress = useCallback(async (time: number, completed: boolean = false) => {
if (!videoRef.current) return;
const progressData: VideoProgress = {
progress: Math.round(time),
};
try {
// First, try to fetch existing progress
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`);
if (response.data.length > 0) {
// If progress exists, update it
const existingProgress = response.data[0];
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
} else {
// If no progress, create a new one
await axiosInstance.post('/videos/progress/', progressData);
}
if (completed) {
setSnackbarMessage('Video progress saved: Completed!');
} else {
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
}
setSnackbarOpen(true);
} catch (err) {
console.error('Failed to save video progress:', err);
setError('Failed to save video progress.');
}
}, [video.id, account?.id]);
// Fetch initial progress when video changes or component mounts
useEffect(() => {
const fetchProgress = async () => {
setIsLoading(true);
setError(null);
try {
const {data,} = await axiosInstance.get<VideoProgressAPI>(`/videos/progress/${video.id}/?user=${account?.id}`);
if (data) {
const progress: VideoProgress = {
current_time: data.progress,
progress: data.progress,
}
setCurrentTime(progress?.current_time || 0);
if (videoRef.current) {
videoRef.current.currentTime = progress.current_time || 0;
}
setSnackbarMessage(`Resuming from ${Math.floor(progress?.current_time || 0)}s`);
setSnackbarOpen(true);
} else {
setCurrentTime(0);
if (videoRef.current) {
videoRef.current.currentTime = 0;
}
}
} catch (err) {
console.error('Failed to fetch video progress:', err);
setError('Failed to load video progress.');
} finally {
setIsLoading(false);
}
};
fetchProgress();
// Cleanup on unmount or video change
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Save final progress when component unmounts or video changes
if (videoRef.current && !videoRef.current.ended) {
saveProgress(videoRef.current.currentTime);
}
};
}, [video.id, account?.id, saveProgress]);
// Video event handlers
const handleTimeUpdate = () => {
if (videoRef.current) {
const newTime = videoRef.current.currentTime;
setCurrentTime(newTime);
// Debounce progress saving to avoid too many API calls
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
saveProgress(newTime);
}, 5000); // Save every 5 seconds of playback
}
};
const handlePlay = () => {
setIsPlaying(true);
if (videoRef.current) {
videoRef.current.play().catch(e => console.error("Error playing video:", e));
}
};
const handlePause = () => {
setIsPlaying(false);
if (videoRef.current) {
videoRef.current.pause();
saveProgress(videoRef.current.currentTime); // Save immediately on pause
}
};
const handleEnded = () => {
setIsPlaying(false);
saveProgress(video.duration, true); // Mark as completed
};
const handleLoadedData = () => {
setIsLoading(false);
// Attempt to play if it was playing before, or if it's the first load
if (videoRef.current && currentTime > 0) {
videoRef.current.currentTime = currentTime;
videoRef.current.play().catch(e => console.error("Error resuming video:", e));
setIsPlaying(true);
}
};
const handleVideoError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
console.error('Video error:', event);
setError('Error loading video. The file might be missing or corrupted.');
setIsLoading(false);
};
const handleReplay = () => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
setCurrentTime(0);
saveProgress(0, false); // Reset progress
videoRef.current.play();
setIsPlaying(true);
}
};
const toggleFullScreen = () => {
if (!videoRef.current) return;
if (!document.fullscreenElement) {
videoRef.current.requestFullscreen().catch(err => {
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
});
} else {
document.exitFullscreen();
}
};
useEffect(() => {
const handleFullScreenChange = () => {
setIsFullScreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
};
}, []);
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
};
const handleSnackbarClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
};
return (
<Paper elevation={3} sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>{video.name}</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>{video.description}</Typography>
<Box sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}>
<video
ref={videoRef}
src={video.videoUrl}
controls={false}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
onLoadedData={handleLoadedData}
onError={handleVideoError}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000',
borderRadius: 2,
}}
>
<Typography>
Your browser does nto support the video tag.
</Typography>
</video>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={isPlaying ? 'Pause' : 'Play'}>
<IconButton onClick={isPlaying ? handlePause : handlePlay} color="primary" size="large">
{isPlaying ? <PauseIcon fontSize="inherit" /> : <PlayArrowIcon fontSize="inherit" />}
</IconButton>
</Tooltip>
<Tooltip title="Replay">
<IconButton onClick={handleReplay} color="primary" size="large">
<ReplayIcon fontSize="inherit" />
</IconButton>
</Tooltip>
<Typography variant="body2" sx={{ ml: 1 }}>
{formatTime(currentTime)} / {formatTime(video.duration)}
</Typography>
</Box>
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
<IconButton onClick={toggleFullScreen} color="primary" size="large">
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />}
</IconButton>
</Tooltip>
</Box>
<Typography variant="body2" sx={{ mt: 2 }}>
Current Progress: {Math.round(video.progress/video.duration*100)}% - Status: {video.status}
</Typography>
</Paper>
);
};
export default VideoPlayer;

View File

@@ -0,0 +1,89 @@
// src/components/VideoPlayerPage.tsx
import React, { useState, useEffect } from 'react';
import { Box, Grid, List, ListItem, ListItemText, Typography, Button, Paper, LinearProgress } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import VideoPlayer from './VideoPlayer';
import { Video } from 'pages/Education/Education';
interface VideoPlayerPageProps {
categoryName: string;
videos: Video[];
onBack: () => void;
// You might pass a function to update video status/progress here
// onVideoProgressUpdate: (videoId: string, progress: number, status: 'completed' | 'in-progress') => void;
}
const VideoPlayerPage: React.FC<VideoPlayerPageProps> = ({ categoryName, videos, onBack }) => {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
// Default to the first video in the category
useEffect(() => {
if (videos.length > 0) {
setSelectedVideo(videos[0]);
}
}, [videos]);
const handleVideoSelect = (video: Video) => {
setSelectedVideo(video);
};
return (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={onBack}
sx={{ mb: 2 }}
>
Back to Categories
</Button>
<Typography variant="h5" component="h2" gutterBottom>
{categoryName} Videos
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
<Typography variant="h6" gutterBottom>Video List</Typography>
<List>
{videos.map((video) => (
<ListItem
key={video.title} // Use a unique ID if available, otherwise title
button
selected={selectedVideo?.title === video.title}
onClick={() => handleVideoSelect(video)}
sx={{ borderBottom: '1px solid #eee' }}
>
<ListItemText
primary={video.title}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{video.description}
</Typography>
<LinearProgress variant="determinate" value={video.progress} sx={{ height: 5, borderRadius: 5, mt: 0.5 }} />
<Typography variant="caption" display="block" align="right">
{`${video.progress.toFixed(0)}% ${video.status === 'completed' ? '(Completed)' : ''}`}
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</Paper>
</Grid>
<Grid item xs={12} md={8}>
{selectedVideo ? (
<VideoPlayer video={selectedVideo} />
) : (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h6" color="text.secondary">Select a video to play</Typography>
</Paper>
)}
</Grid>
</Grid>
</Box>
);
};
export default VideoPlayerPage;

View File

@@ -0,0 +1,152 @@
import { Button, Box, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography, Autocomplete, TextField, Paper } from "@mui/material";
import { ReactElement, useState } from "react";
import {axiosInstance} from '../../../../../axiosApi'
import { PropertiesAPI } from "types";
import { AxiosResponse } from "axios";
type CreateOfferDialogProps = {
showDialog: boolean;
closeDialog: () => void;
createOffer: () => void;
}
type OfferPropertyType = {
address: string;
marketValue: string;
property_id: number;
}
const CreateOfferDialog = ({showDialog, closeDialog, createOffer}: CreateOfferDialogProps): ReactElement => {
const [inputValue, setInputValue] = useState('');
const [options, setOptions] = useState<string[]>([]);
const [filteredProperties, setFilteredProperties] = useState<OfferPropertyType[]>([]);
const [selectedProperty, setSelectedProperty] = useState<OfferPropertyType | null | undefined>(null);
const [offerAmount, setOfferAmount] = useState<number | ''>('');
const [closingDuration, setClosingDuration] = useState<number | ''>('');
const handleInputChange = async (event, newInputValue) => {
setInputValue(newInputValue);
if(newInputValue){
const {data,}: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(`/properties/?search=${newInputValue}`)
const filteredPropertieResults: OfferPropertyType[]= data.map(item => {
return {
address: item.address,
marketValue: item.market_value,
property_id: item.id
}
})
setFilteredProperties(filteredPropertieResults);
const filteredPropertiesNames: string[] = data.map(item => {
return item.address
})
setOptions(filteredPropertiesNames);
}else{
setOptions([]);
}
};
return(
<Dialog
open={showDialog}
onClose={closeDialog}
>
<DialogTitle>
Create new offer
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction='column' sx={{flexGrow: 1}}>
<Autocomplete
value={null}
options={options}
onChange={(event, newValue) => {
const selectedAddress = options.find(item => item === newValue)
if(selectedAddress){
setSelectedProperty(filteredProperties.find(item => item.address === selectedAddress))
}
}}
inputValue={inputValue}
onInputChange={handleInputChange}
noOptionsText={"Type the address to search for"}
renderInput={(params) => (<TextField {...params} label="search for a property" variant="outlined" />)}>
</Autocomplete>
{!selectedProperty? (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
<Typography variant="h6">Search for a property to create an offer </Typography>
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
</Box>
):(
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
<Typography variant="h6">{selectedProperty.address} </Typography>
</Box>
)}
</Stack>
<Stack direction='column' >
<Typography>
Offer Price
</Typography>
<TextField
label="Offer Amount ($)"
type="number"
defaultValue={selectedProperty? selectedProperty.marketValue : ''}
value={offerAmount}
onChange={(e) => setOfferAmount(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
disabled={!selectedProperty}
/>
<TextField
label="Closing Duration (days)"
type="number"
value={closingDuration}
onChange={(e) => setClosingDuration(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
disabled={!selectedProperty}
/>
<TextField
label="Other"
type="text"
// value={closingDuration}
// onChange={(e) => setClosingDuration(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
disabled={!selectedProperty}
/>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button
onClick={async () =>
await createOffer(selectedProperty.property_id)
}
disabled={!selectedProperty}
>Create</Button>
</DialogActions>
</Dialog>
)
}
export default CreateOfferDialog;

View File

@@ -0,0 +1,500 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Grid,
Autocomplete,
CircularProgress,
Box,
Alert,
} from '@mui/material';
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
import MapComponent from '../../../../base/MapComponent';
import {
AutocompleteDataResponseAPI,
AutocompleteResponseAPI,
PropertiesAPI,
PropertResponseDataAPI,
PropertyResponseAPI,
SaleHistoryAPI,
SchoolAPI,
} from 'types';
import { test_property_search } from 'data/mock_property_search';
import { extractLatLon } from 'utils';
import { test_autocomplete } from 'data/mock_autocomplete_results';
interface AddPropertyDialogProps {
open: boolean;
onClose: () => void;
onAddProperty: (
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
) => void;
}
export interface PlacePrediction {
description: string;
place_id: string;
}
const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, onAddProperty }) => {
const initalValues: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'> = {
address: '',
street: '',
city: '',
state: '',
zip_code: '',
market_value: '',
loan_amount: '',
loan_term: 0,
loan_start_date: '',
pictures: [],
description: '',
sq_ft: 0,
features: [],
num_bedrooms: 0,
num_bathrooms: 0,
latitude: undefined,
longitude: undefined,
realestate_api_id: 0,
views: 0,
saves: 0,
property_status: 'off_market',
schools: [],
};
const [newProperty, setNewProperty] = useState<
Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>
>({
...initalValues,
});
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
const [selectedPlace, setSelectedPlace] = useState<PlacePrediction | null>(null);
// Initialize Google Maps Places Service (requires Google Maps API key loaded globally)
// This is a simplified approach. For a more robust solution, use @vis.gl/react-google-maps useMapsLibrary hook
useEffect(() => {
if (!window.google || !window.google.maps || !window.google.maps.places) {
console.warn('Google Maps Places API not loaded. Autocomplete will not function.');
// You might want to handle this by displaying a message or disabling autocomplete
}
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setNewProperty((prev) => ({ ...prev, [name]: value }));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleAddressAutocompleteInputChange = async (
event: React.SyntheticEvent,
value: string,
) => {
const test: boolean = true;
let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) {
if (test) {
data = test_autocomplete.data.filter((item) => item.address.includes(value));
// filter the data here
} else {
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
'AutoComplete',
{
search: value,
},
);
}
setAutocompleteOptions(
data.map((item) => ({
description: item.address,
place_id: item.id,
})),
);
} else {
console.log('we need more characters');
}
setNewProperty((prev) => ({ ...prev, address: value }));
// if (value.length > 2 && window.google && window.google.maps && window.google.maps.places) {
// setAutocompleteLoading(true);
// const service = new window.google.maps.places.AutocompleteService();
// service.getPlacePredictions({ input: value }, (predictions, status) => {
// if (status === window.google.maps.places.PlacesServiceStatus.OK && predictions) {
// setAutocompleteOptions(predictions.map(p => ({ description: p.description, place_id: p.place_id })));
// } else {
// setAutocompleteOptions([]);
// }
// setAutocompleteLoading(false);
// });
// } else {
// setAutocompleteOptions([]);
// }
};
const handleAddressAutocompleteChange = async (
event: React.SyntheticEvent,
value: PlacePrediction | null,
) => {
setSelectedPlace(value);
console.log('here we go', value);
if (value) {
console.log('find the test data');
const test: boolean = true;
if (test) {
const parts: string[] =
test_property_search.data.currentMortgages[0].recordingDate.split('T');
// get the features
// get the schools
const schools: Omit<SchoolAPI, 'id' | 'created_at' | 'last_updated'>[] =
test_property_search.data.schools.map((item) => {
const coordinates = extractLatLon(item.location);
return {
city: item.city,
state: item.state,
zip_code: item.zip,
latitude: coordinates?.latitude,
longitude: coordinates?.longitude,
school_type: item.type,
enrollment: item.enrollment,
grades: item.grades,
name: item.name,
parent_rating: item.parentRating,
rating: item.rating,
};
});
console.log(schools);
// get the sale history
const sale_history: Omit<SaleHistoryAPI, 'id' | 'created_at' | 'last_updated'>[] =
test_property_search.data.saleHistory.map((item) => {
return {
seq_no: item.seqNo,
sale_date: item.saleDate,
sale_amount: item.saleAmount,
};
});
setNewProperty({
address: test_property_search.data.propertyInfo.address.address,
street: test_property_search.data.ownerInfo.mailAddress.address,
city: test_property_search.data.propertyInfo.address.city,
state: test_property_search.data.propertyInfo.address.state,
zip_code: test_property_search.data.propertyInfo.address.zip,
latitude: test_property_search.data.propertyInfo.latitude,
longitude: test_property_search.data.propertyInfo.longitude,
market_value: test_property_search.data.estimatedValue.toString(),
loan_amount: test_property_search.data.currentMortgages[0].amount.toString(),
loan_term: test_property_search.data.currentMortgages[0].term,
loan_start_date: parts[0],
description: '',
features: [],
pictures: [],
num_bedrooms: test_property_search.data.propertyInfo.bedrooms,
num_bathrooms: test_property_search.data.propertyInfo.bathrooms,
sq_ft: test_property_search.data.propertyInfo.buildingSquareFeet,
realestate_api_id: test_property_search.data.id,
views: 0,
saves: 0,
property_status: 'off_market',
schools: schools,
tax_info: {
assessed_value: test_property_search.data.taxInfo.assessedValue,
assessment_year: test_property_search.data.taxInfo.assessmentYear,
tax_amount: Number(test_property_search.data.taxInfo.taxAmount),
year: test_property_search.data.taxInfo.year,
},
sale_info: sale_history,
});
}
}
};
// Simple file upload simulation (you'd replace this with actual file handling and storage)
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
// In a real app, you'd upload these files and get URLs back
const imageUrls = filesArray.map((file) => URL.createObjectURL(file)); // For display purposes
setNewProperty((prev) => ({ ...prev, pictures: [...prev.pictures, ...imageUrls] }));
}
};
const validateForm = () => {
const errors: { [key: string]: string } = {};
if (!newProperty.address.trim()) {
errors.address = 'Address is required.';
}
if (!newProperty.city.trim()) {
errors.city = 'City is required.';
}
if (!newProperty.state.trim()) {
errors.state = 'State is required.';
}
if (!newProperty.zip_code.trim()) {
errors.zip_code = 'Zip code is required.';
}
if (newProperty.sq_ft <= 0) {
errors.sq_ft = 'Square footage must be greater than 0.';
}
if (newProperty.num_bedrooms < 0) {
errors.num_bedrooms = 'Number of bedrooms cannot be negative.';
}
if (newProperty.num_bathrooms < 0) {
errors.num_bathrooms = 'Number of bathrooms cannot be negative.';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleAdd = () => {
if (validateForm()) {
onAddProperty(newProperty);
onClose();
// Reset form
setNewProperty(initalValues);
setAutocompleteOptions([]);
setSelectedPlace(null);
setFormErrors({});
}
};
const handleCloseAndReset = () => {
onClose();
setNewProperty(initalValues);
setAutocompleteOptions([]);
setSelectedPlace(null);
setFormErrors({});
};
return (
<Dialog open={open} onClose={handleCloseAndReset} fullWidth maxWidth="md">
<DialogTitle>Add New Property</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid item xs={12}>
<Autocomplete
options={autocompleteOptions}
getOptionLabel={(option) => option.description}
inputValue={newProperty.address}
noOptionsText={'Type at least 3 characters'}
onInputChange={handleAddressAutocompleteInputChange}
onChange={handleAddressAutocompleteChange}
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
loading={autocompleteLoading}
renderInput={(params) => (
<TextField
{...params}
label="Address"
name="address"
fullWidth
required
error={!!formErrors.address}
helperText={formErrors.address}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{autocompleteLoading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="City"
name="city"
value={newProperty.city}
onChange={handleInputChange}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="State"
name="state"
value={newProperty.state}
onChange={handleInputChange}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Zip Code"
name="zip_code"
value={newProperty.zip_code}
onChange={handleInputChange}
required
error={!!formErrors.zip_code}
helperText={formErrors.zip_code}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Description"
name="description"
multiline
rows={3}
value={newProperty.description}
onChange={handleInputChange}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Square Footage"
name="sq_ft"
type="number"
value={newProperty.sq_ft || ''}
onChange={handleInputChange}
error={!!formErrors.sq_ft}
helperText={formErrors.sq_ft}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="# Bedrooms"
name="num_bedrooms"
type="number"
value={newProperty.num_bedrooms || ''}
onChange={handleInputChange}
error={!!formErrors.num_bedrooms}
helperText={formErrors.num_bedrooms}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="# Bathrooms"
name="num_bathrooms"
type="number"
value={newProperty.num_bathrooms || ''}
onChange={handleInputChange}
error={!!formErrors.num_bathrooms}
helperText={formErrors.num_bathrooms}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Features (comma-separated)"
name="features"
value={newProperty.features.join(', ')}
onChange={(e) =>
setNewProperty((prev) => ({
...prev,
features: e.target.value.split(',').map((f) => f.trim()),
}))
}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Market Value"
name="market_value"
value={newProperty.market_value}
onChange={handleInputChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Loan Amount"
name="loan_amount"
value={newProperty.loan_amount}
onChange={handleInputChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Loan Term (years)"
name="loan_term"
type="number"
value={newProperty.loan_term || ''}
onChange={handleInputChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Loan Start Date"
name="loan_start_date"
type="date"
InputLabelProps={{ shrink: true }}
value={newProperty.loan_start_date}
onChange={handleInputChange}
/>
</Grid>
<Grid item xs={12}>
<Button variant="contained" component="label">
Upload Pictures
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
</Button>
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{newProperty.pictures.map((url, index) => (
<img
key={index}
src={url}
alt={`Uploaded ${index}`}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Box>
</Grid>
{newProperty.latitude && newProperty.longitude && (
<Grid item xs={12}>
<MapComponent
lat={newProperty.latitude}
lng={newProperty.longitude}
address={newProperty.address}
/>
</Grid>
)}
</Grid>
{Object.keys(formErrors).length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
Please correct the errors in the form.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAndReset}>Cancel</Button>
<Button onClick={handleAdd} variant="contained" color="primary">
Add Property
</Button>
</DialogActions>
</Dialog>
);
};
export default AddPropertyDialog;

View File

@@ -0,0 +1,118 @@
import React, { useState, useEffect, ReactElement } from 'react';
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
import { AttorneyAPI, UserAPI } from '../types/api';
import { ProfileProps } from 'pages/Profile/Profile';
import DashboardLoading from '../Dashboard/DashboardLoading';
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import AttorneyProfileCard from './AttorneyProfileCard';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../../../../axiosApi';
import { useNavigate } from 'react-router-dom';
const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
const [attorney, setAttorney] = useState<AttorneyAPI | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const navigate = useNavigate();
const fetchAttorneyData = async () => {
setLoading(true);
setError(null);
try {
const { data }: AxiosResponse<AttorneyAPI[]> = await axiosInstance.get('/attorney/');
if (data.length > 0) {
setAttorney(data[0]);
}
// setAttorney(initialAttorneyData);
} catch (err: any) {
setError(err.message || 'An error occurred while fetching profile data.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAttorneyData();
}, []);
const handleUpgradeSubscription = () => {
if (attorney) {
navigate('/upgrade/');
//setAttorney((prev) => (prev ? { ...prev, user: { ...prev.user, tier: 'premium' } } : null));
//setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
//setTimeout(() => setMessage(null), 3000);
}
};
const handleSaveAttorneyProfile = (updatedAttorney: AttorneyAPI) => {
// In a real application, you'd send this data to your backend API
try {
let newUpdatedAttorney;
// if the email is the same, remove it
if (updatedAttorney.user.email === account.email) {
const { user, ...reducedVendor } = updatedAttorney;
const { email, ...reducedUser } = user;
newUpdatedAttorney = {
user: {
profile_created: true,
...reducedUser,
},
...reducedVendor,
};
} else {
newUpdatedAttorney = updatedAttorney;
}
newUpdatedAttorney.user.profile_created = true;
console.log(newUpdatedAttorney);
const { data, error } = axiosInstance.patch(`/attorney/${account.id}/`, {
...newUpdatedAttorney,
});
console.log(data, error);
setAttorney({
profile: { profile_created: true, ...updatedAttorney.profile },
...updatedAttorney,
});
setMessage({ type: 'success', text: 'Profile updated successfully!' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
console.log(error);
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
setTimeout(() => setMessage(null), 3000);
}
// console.log('Saving attorney profile:', updatedAttorney);
// setAttorney(updatedAttorney);
// setMessage({ type: 'success', text: 'Profile updated successfully!' });
// setTimeout(() => setMessage(null), 3000);
};
if (loading) {
return <DashboardLoading />;
}
if (error) {
return <DashboardErrorPage />;
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Attorney Profile
</Typography>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
<AttorneyProfileCard
attorney={attorney}
onUpgrade={handleUpgradeSubscription}
onSave={handleSaveAttorneyProfile}
/>
</Container>
);
};
export default AttorneyProfile;

View File

@@ -0,0 +1,503 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Grid,
TextField,
Box,
Alert,
IconButton,
Chip,
Avatar,
Autocomplete,
CircularProgress,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
import MapComponent from 'components/base/MapComponent';
import { AttorneyAPI, AutocompleteDataResponseAPI } from 'types';
import { PlacePrediction } from './AddPropertyDialog';
import { test_autocomplete } from 'data/mock_autocomplete_results';
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
import { extractLatLon } from 'utils';
interface AttorneyProfileCardProps {
attorney: AttorneyAPI;
onUpgrade: () => void; // Assuming attorneys can also upgrade their tier
onSave: (updatedAttorney: AttorneyAPI) => void;
}
const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
attorney,
onUpgrade,
onSave,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editedAttorney, setEditedAttorney] = useState<AttorneyAPI>(attorney);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
const handleEditToggle = () => {
if (isEditing) {
setEditedAttorney(attorney); // Revert on cancel
setFormErrors({});
}
setIsEditing(!isEditing);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setEditedAttorney((prev) => ({
...prev,
[name]: value,
// Handle nested user properties if you allow editing them here
user:
name === 'email' || name === 'first_name' || name === 'last_name'
? { ...prev.user, [name]: value }
: prev.user,
}));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const numValue = parseInt(value);
setEditedAttorney((prev) => ({
...prev,
[name]: isNaN(numValue) ? '' : numValue, // Allow empty string for numerical input
}));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleArrayChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
field: keyof AttorneyAPI,
) => {
const value = e.target.value;
setEditedAttorney((prev) => ({
...prev,
[field]: value
.split(',')
.map((item) => item.trim())
.filter((item) => item !== ''),
}));
};
const handleProfilePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const imageUrl = URL.createObjectURL(file);
setEditedAttorney((prev) => ({ ...prev, profile_picture: imageUrl }));
}
};
const handleAddressAutocompleteInputChange = async (
event: React.SyntheticEvent,
value: string,
) => {
const test: boolean = true;
let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) {
if (test) {
data = test_autocomplete.data.filter((item) => item.address.includes(value));
// filter the data here
} else {
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
'AutoComplete',
{
search: value,
},
);
}
setAutocompleteOptions(
data.map((item) => ({
description: item.address,
place_id: item.id,
})),
);
} else {
console.log('we need more characters');
}
};
const handleAddressAutocompleteChange = async (
event: React.SyntheticEvent,
value: PlacePrediction | null,
) => {
if (1) {
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
if (data.length > 0) {
const item = data[0];
const coordinates = extractLatLon(item.location);
setEditedAttorney((prev) => ({
...prev,
address: item.address,
city: item.city,
state: item.state,
zip_code: item.zip,
latitude: Number(coordinates.latitude),
longitude: Number(coordinates.longitude),
}));
}
} else {
// use the api here
}
};
console.log(editedAttorney);
const validateForm = () => {
const errors: { [key: string]: string } = {};
if (!editedAttorney.firm_name.trim()) {
errors.firm_name = 'Firm name is required.';
}
if (!editedAttorney.phone_number.trim()) {
errors.phone_number = 'Phone number is required.';
} else if (!/^\d{10}$/.test(editedAttorney.phone_number.replace(/\D/g, ''))) {
errors.phone_number = 'Invalid phone number format (10 digits).';
}
if (!editedAttorney.address.trim()) {
errors.address = 'Address is required.';
}
if (!editedAttorney.city.trim()) {
errors.city = 'City is required.';
}
if (!editedAttorney.state.trim()) {
errors.state = 'State is required.';
}
if (!editedAttorney.zip_code.trim()) {
errors.zip_code = 'Zip code is required.';
}
if (editedAttorney.years_experience < 0) {
errors.years_experience = 'Years of experience cannot be negative.';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave(editedAttorney);
setIsEditing(false);
}
};
const displayLat = editedAttorney.latitude ?? 34.0522; // Default to LA for demo
const displayLng = editedAttorney.longitude ?? -118.2437; // Default to LA for demo
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box display="flex" alignItems="center" gap={2}>
<Avatar
src={
editedAttorney.profile_picture ||
'https://via.placeholder.com/150/808080/FFFFFF?text=ATTY'
}
alt={`${editedAttorney.user.first_name} ${editedAttorney.user.last_name}`}
sx={{ width: 80, height: 80 }}
/>
<Box>
<Typography variant="h5" gutterBottom>
Attorney Profile Information
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{editedAttorney.firm_name}
</Typography>
</Box>
</Box>
{isEditing ? (
<Box>
<IconButton color="primary" onClick={handleSave}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditToggle}>
<EditIcon />
</IconButton>
)}
</Box>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedAttorney.user.first_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedAttorney.user.last_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedAttorney.user.email}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Firm Name"
name="firm_name"
value={editedAttorney.firm_name}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.firm_name}
helperText={formErrors.firm_name}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Phone Number"
name="phone_number"
value={editedAttorney.phone_number}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.phone_number}
helperText={formErrors.phone_number}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={autocompleteOptions}
getOptionLabel={(option) => option.description}
inputValue={editedAttorney.adress}
noOptionsText={'Type at least 3 characters'}
onInputChange={handleAddressAutocompleteInputChange}
onChange={handleAddressAutocompleteChange}
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
loading={autocompleteLoading}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label="Address"
name="address"
required
error={!!formErrors.address}
helperText={formErrors.address}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{autocompleteLoading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
{/*<TextField
fullWidth
label="Address"
name="address"
value={editedAttorney.address}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.address}
helperText={formErrors.address}
/>*/}
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="City"
name="city"
value={editedAttorney.city}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="State"
name="state"
value={editedAttorney.state}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Zip Code"
name="zip_code"
value={editedAttorney.zip_code}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.zip_code}
helperText={formErrors.zip_code}
/>
</Grid>
{/*<Grid item xs={12}>
<TextField
fullWidth
label="Specialties (comma-separated)"
name="specialties"
value={editedAttorney.specialties.join(', ')}
onChange={(e) => handleArrayChange(e, 'specialties')}
disabled={!isEditing}
/>
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{editedAttorney.specialties.map((specialty, index) => (
<Chip key={index} label={specialty} size="small" />
))}
</Box>
</Grid>*/}
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Years of Experience"
name="years_experience"
type="number"
value={editedAttorney.years_experience || ''}
onChange={handleNumericChange}
disabled={!isEditing}
error={!!formErrors.years_experience}
helperText={formErrors.years_experience}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Licensed States (comma-separated)"
name="licensed_states"
value={editedAttorney.licensed_states.join(', ')}
onChange={(e) => handleArrayChange(e, 'licensed_states')}
disabled={!isEditing}
/>
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{editedAttorney.licensed_states.map((state, index) => (
<Chip key={index} label={state} size="small" variant="outlined" />
))}
</Box>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Website URL"
name="website"
value={editedAttorney.website || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Biography"
name="bio"
multiline
rows={4}
value={editedAttorney.bio || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
component="label"
startIcon={<AddPhotoAlternateIcon />}
disabled={!isEditing}
>
Upload Profile Picture
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
</Button>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle1">
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography>
{attorney.user.tier === 'basic' && (
<Button
variant="contained"
color="primary"
onClick={onUpgrade}
sx={{ mt: 1 }}
disabled={isEditing}
>
Upgrade to Premium
</Button>
)}
</Grid>
{editedAttorney.latitude && editedAttorney.longitude && (
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
Firm Location on Map:
</Typography>
{/* Assuming MapComponent accepts center, zoom, and a single property for display */}
<MapComponent
lat={editedAttorney.latitude}
lng={editedAttorney.longitude}
address={editedAttorney.address}
/>
</Grid>
)}
</Grid>
{Object.keys(formErrors).length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
Please correct the errors in the form.
</Alert>
)}
</CardContent>
</Card>
);
};
export default AttorneyProfileCard;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { useMap, useMapsLibrary } from '@vis.gl/react-google-maps';
interface DrawingManagerProps {
onBoxDrawn: (bounds: any) => void;
}
const DrawingManager: React.FC<DrawingManagerProps> = ({ onBoxDrawn }) => {
const mapsLibrary = useMapsLibrary('drawing');
const map = useMap();
React.useEffect(() => {
if (!mapsLibrary || !map) {
return;
}
const drawingManager = new mapsLibrary.drawing.DrawingManager({
drawingControl: true,
drawingControlOptions: {
position: window.google.maps.ControlPosition.TOP_CENTER,
drawingModes: [window.google.maps.drawing.OverlayType.RECTANGLE],
},
rectangleOptions: {
fillColor: '#FF0000',
fillOpacity: 0.1,
strokeWeight: 2,
strokeColor: '#FF0000',
clickable: false,
editable: true,
zIndex: 1,
},
});
drawingManager.setMap(map);
const listener = mapsLibrary.event.addListener(drawingManager, 'rectanglecomplete', (rectangle: google.maps.Rectangle) => {
const bounds = rectangle.getBounds();
if (bounds) {
onBoxDrawn({
ne: { lat: bounds.getNorthEast().lat(), lng: bounds.getNorthEast().lng() },
sw: { lat: bounds.getSouthWest().lat(), lng: bounds.getSouthWest().lng() }
});
}
drawingManager.setDrawingMode(null);
setTimeout(() => {
rectangle.setMap(null);
}, 5000);
});
return () => {
mapsLibrary.event.removeListener(listener);
drawingManager.setMap(null);
};
}, [mapsLibrary, map, onBoxDrawn]);
return null;
};
export default DrawingManager;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Divider } from '@mui/material';
interface EstimatedMonthlyCostCardProps {
price: number;
}
const EstimatedMonthlyCostCard: React.FC<EstimatedMonthlyCostCardProps> = ({ price }) => {
const calculateMonthlyPayment = (principal: number, interestRate: number, loanTerm: number) => {
const monthlyInterestRate = interestRate / 12;
const numberOfPayments = loanTerm * 12;
const numerator = principal * monthlyInterestRate * Math.pow(1 + monthlyInterestRate, numberOfPayments);
const denominator = Math.pow(1 + monthlyInterestRate, numberOfPayments) - 1;
return denominator !== 0 ? numerator / denominator : 0;
};
const downPayment = price * 0.20; // 20% down payment
const loanAmount = price - downPayment;
const interestRate = 0.07; // 7% annual interest rate
const loanTerm = 30; // 30-year term
const monthlyMortgage = calculateMonthlyPayment(loanAmount, interestRate, loanTerm);
const monthlyPropertyTax = (price * 0.015) / 12; // 1.5% of value annually
const monthlyInsurance = 100; // Flat estimate
const monthlyHoa = 50; // Flat estimate
const totalMonthlyCost = monthlyMortgage + monthlyPropertyTax + monthlyInsurance + monthlyHoa;
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Estimated Monthly Cost
</Typography>
<Box display="flex" justifyContent="space-between" mt={2}>
<Typography>Mortgage</Typography>
<Typography>${monthlyMortgage.toFixed(2)}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>Property Tax</Typography>
<Typography>${monthlyPropertyTax.toFixed(2)}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>Home Insurance</Typography>
<Typography>${monthlyInsurance.toFixed(2)}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>HOA Fees</Typography>
<Typography>${monthlyHoa.toFixed(2)}</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Box display="flex" justifyContent="space-between" sx={{ fontWeight: 'bold' }}>
<Typography>Total Estimate</Typography>
<Typography>${totalMonthlyCost.toFixed(2)}</Typography>
</Box>
</CardContent>
</Card>
);
};
export default EstimatedMonthlyCostCard;

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Card, CardContent, Typography, TextField, Button, Box, Alert } from '@mui/material';
interface OfferSubmissionCardProps {
onOfferSubmit: (offerAmount: number) => void;
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
}
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
onOfferSubmit,
listingStatus,
}) => {
const [offerAmount, setOfferAmount] = useState<string>('');
const [submitted, setSubmitted] = useState(false);
if (listingStatus === 'active') {
const handleSubmit = () => {
const amount = parseFloat(offerAmount);
if (amount > 0) {
onOfferSubmit(amount);
setSubmitted(true);
setTimeout(() => setSubmitted(false), 5000);
}
};
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Submit an Offer
</Typography>
<TextField
fullWidth
label="Your Offer Amount ($)"
type="number"
value={offerAmount}
onChange={(e) => setOfferAmount(e.target.value)}
sx={{ mb: 2 }}
/>
<Button variant="contained" color="primary" fullWidth onClick={handleSubmit}>
Submit Offer
</Button>
{submitted && (
<Alert severity="success" sx={{ mt: 2 }}>
Your offer of ${offerAmount} has been submitted!
</Alert>
)}
</CardContent>
</Card>
);
} else {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Submit an Offer
</Typography>
<Typography variant="caption" gutterBottom>
Offer is not available at the moment because the list is not active
</Typography>
</CardContent>
</Card>
);
}
};
export default OfferSubmissionCard;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Card, CardContent, Typography, List, ListItem, ListItemText, Divider } from '@mui/material';
import { OpenHouseAPI } from 'types';
interface OpenHouseCardProps {
openHouses: OpenHouseAPI[] | undefined;
}
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
if(openHouses){
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Open House Information
</Typography>
{openHouses.length > 0 ? (
<List dense>
{openHouses.map((openHouse, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemText
primary={`${openHouse.date} at ${openHouse.time}`}
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`}
/>
</ListItem>
{index < openHouses.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
No upcoming open houses scheduled.
</Typography>
)}
</CardContent>
</Card>
);
}else{
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Open House Information
</Typography>
<Typography variant="body2" color="text.secondary">
No upcoming open houses scheduled.
</Typography>
</CardContent>
</Card>
);
}
};
export default OpenHouseCard;

View File

@@ -0,0 +1,188 @@
import React, { useContext, useState } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Grid,
TextField,
Box,
Alert,
IconButton,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import { UserAPI } from 'types';
import { AccountContext } from 'contexts/AccountContext';
interface ProfileCardProps {
user: UserAPI;
onUpgrade: () => void;
onSave: (updatedUser: UserAPI) => void;
}
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) => {
const [isEditing, setIsEditing] = useState(false);
const [editedUser, setEditedUser] = useState<UserAPI>(user);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
const handleEditToggle = () => {
if (isEditing) {
// Cancel editing, revert to original user data
setEditedUser(user);
setFormErrors({});
}
setIsEditing(!isEditing);
};
console.log(editedUser);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setEditedUser((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error for the field being edited
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const validateForm = () => {
const errors: { [key: string]: string } = {};
if (!editedUser.first_name.trim()) {
errors.first_name = 'First name is required.';
}
if (!editedUser.last_name.trim()) {
errors.last_name = 'Last name is required.';
}
if (!editedUser.email.trim()) {
errors.email = 'Email is required.';
} else if (!/\S+@\S+\.\S+/.test(editedUser.email)) {
errors.email = 'Invalid email address.';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave(editedUser);
setIsEditing(false);
}
};
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h5" gutterBottom>
Profile Information
</Typography>
{isEditing ? (
<Box>
<IconButton color="primary" onClick={handleSave}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditToggle}>
<EditIcon />
</IconButton>
)}
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedUser.first_name}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.first_name}
helperText={formErrors.first_name}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedUser.last_name}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.last_name}
helperText={formErrors.last_name}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedUser.email}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.email}
helperText={formErrors.email}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle1">
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography>
{user.tier === 'basic' && (
<Button variant="contained" color="primary" onClick={onUpgrade} sx={{ mt: 1 }}>
Upgrade to Premium
</Button>
)}
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle1">Notification Settings:</Typography>
{/* Example Checkboxes - You'd manage these with state too */}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<label>
<input
type="checkbox"
name="productUpdateEmails"
checked={true} // Placeholder
onChange={() => {}} // Placeholder
disabled={!isEditing}
/>{' '}
Product Update Emails
</label>
<label>
<input
type="checkbox"
name="communicationEmails"
checked={true} // Placeholder
onChange={() => {}} // Placeholder
disabled={!isEditing}
/>{' '}
Communication Emails
</label>
</Box>
</Grid>
</Grid>
{Object.keys(formErrors).length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
Please correct the errors in the form.
</Alert>
)}
</CardContent>
</Card>
);
};
export default ProfileCard;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Grid,
Box,
ImageList,
ImageListItem,
ImageListItemBar,
Button,
} from '@mui/material';
import MapComponent from '../../../../base/MapComponent';
import { PropertiesAPI } from 'types';
interface PropertyCardProps {
property: PropertiesAPI;
}
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
// Dummy latitude and longitude for demonstration
// In a real app, you'd geocode the address to get these.
const demoLat = 34.0522;
const demoLng = -118.2437; // Example: Los Angeles coordinates
console.log(property)
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{property.address}, {property.city}, {property.state} {property.zip_code}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
{property.pictures && property.pictures.length > 0 && (
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}>
{property.pictures.map((item, index) => (
<ImageListItem key={index}>
<img
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
src={`${item}?w=164&h=164&fit=crop&auto=format`}
alt={`Property image ${index + 1}`}
loading="lazy"
/>
<ImageListItemBar
title={`Image ${index + 1}`}
/>
</ImageListItem>
))}
</ImageList>
)}
<Typography variant="body1" sx={{ mt: 2 }}>
<strong>Description:</strong>
</Typography>
{ property.description ? (
<Typography variant="body2" color="textSecondary">
{property.description}
</Typography>
) : (
<Button variant='contained'>
Generate Description
</Button>
)}
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="body1">
<strong>Stats:</strong>
</Typography>
<Typography variant="body2">
Sq Ft: {property.sq_ft || 'N/A'}
</Typography>
<Typography variant="body2">
Bedrooms: {property.num_bedrooms || 'N/A'}
</Typography>
<Typography variant="body2">
Bathrooms: {property.num_bathrooms || 'N/A'}
</Typography>
<Typography variant="body2">
Features: {property.features && property.features.length > 0
? property.features.join(', ')
: 'None'}
</Typography>
<Typography variant="body2">
Market Value: ${property.market_value || 'N/A'}
</Typography>
<Typography variant="body2">
Loan Amount: ${property.loan_amount || 'N/A'}
</Typography>
<Typography variant="body2">
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
</Typography>
<Typography variant="body2">
Loan Start Date: {property.loan_start_date || 'N/A'}
</Typography>
{property.latitude && property.longitude ? (
<MapComponent lat={property.latitude} lng={property.longitude} address={property.address} />
) : (
<p>Error loading the map</p>
)}
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export default PropertyCard;

View File

@@ -0,0 +1,230 @@
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI } from 'types';
import ProfileCard from './ProfileCard';
import { AxiosResponse } from 'axios';
import PropertyCard from './PropertyCard.';
import AddPropertyDialog from './AddPropertyDialog';
import { axiosInstance } from '../../../../../axiosApi';
import PropertyDetailCard from '../Property/PropertyDetailCard';
import { AccountContext } from 'contexts/AccountContext';
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import DashboardLoading from '../Dashboard/DashboardLoading';
import { ProfileProps } from 'pages/Profile/Profile';
import { useNavigate } from 'react-router-dom';
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate();
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
useEffect(() => {
const fetchPropertyOwner = async () => {
try {
setLoadingData(true);
const { data }: AxiosResponse<PropertyOwnerAPI[]> =
await axiosInstance.get(`/property-owners/`);
if (data.length > 0) {
setUser(data[0]);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
fetchPropertyOwner();
}, []);
const [properties, setProperties] = useState<PropertiesAPI[]>([]); //initialPropertiesData);
const [openAddPropertyDialog, setOpenAddPropertyDialog] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
const fetchProperties = async () => {
try {
setLoadingData(true);
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
if (data.length > 0) {
setProperties(data);
console.log('setting the user to: ', data[0].owner);
setUser(data[0].owner);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
fetchProperties();
}, []);
const handleUpgradeSubscription = async () => {
navigate('/upgrade/');
// const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
// ...user.user,
// tier: 'premium',
// });
// if (data !== null && user) {
// const updateUser: PropertyOwnerAPI = {
// ...user,
// user: data,
// };
// setUser(updateUser);
// }
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
// setTimeout(() => setMessage(null), 3000);
};
const handleSaveProfile = async (editedUser: UserAPI) => {
editedUser.profile_created = true;
const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
...editedUser,
});
if (data !== null && user) {
const updateUser: PropertyOwnerAPI = {
...user,
user: data,
};
setUser(updateUser);
}
setMessage({ type: 'success', text: 'Profile updated successfully!' });
setTimeout(() => setMessage(null), 3000);
};
const handleOpenAddPropertyDialog = () => {
setOpenAddPropertyDialog(true);
};
const handleCloseAddPropertyDialog = () => {
setOpenAddPropertyDialog(false);
};
const handleAddProperty = (
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
) => {
if (user) {
const newProperty: PropertyRequestAPI = {
...newPropertyData,
owner: user.user.id,
// created_at: new Date().toISOString().split('T')[0],
// last_updated: new Date().toISOString().split('T')[0],
};
newProperty.created_at = new Date().toISOString().split('T')[0];
newProperty.last_updated = new Date().toISOString().split('T')[0];
newProperty.open_houses = [];
console.log(newProperty);
const { data, error } = axiosInstance.post('/properties/', {
...newProperty,
});
const updateNewProperty: PropertiesAPI = {
...newProperty,
owner: user,
};
setProperties((prev) => [...prev, updateNewProperty]);
setMessage({ type: 'success', text: 'Property added successfully!' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property
console.log('Saving property: IMPLEMENT ME', updatedProperty);
};
const handleDeleteProperty = async (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
try {
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete(
`/properties/${propertyId}/`,
);
console.log(data);
// remove the proprty from the list
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId));
// const indexToRemove = properties.findIndex(property => property.id === propertyId);
// console.log(indexToRemove)
// if (indexToRemove !== -1) {
// const updatedProperties = properties.splice(indexToRemove, 1)
// console.log(updatedProperties)
// setProperties(updatedProperties);
// }
} catch {
console.log('error removing');
}
};
console.log(user);
if (loadingData) {
return <DashboardLoading />;
}
if (user === null) {
return <DashboardErrorPage />;
} else {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
User Profile Dashboard
</Typography>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
<ProfileCard
user={user.user}
onUpgrade={handleUpgradeSubscription}
onSave={handleSaveProfile}
/>
<Divider sx={{ my: 4 }} />
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
My Properties
</Typography>
<Button variant="contained" color="primary" onClick={handleOpenAddPropertyDialog}>
Add Property
</Button>
</Box>
{properties.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
You currently have no properties listed.
</Typography>
</Paper>
) : (
<Grid container spacing={3}>
{properties.map((property) => (
<Grid item xs={12} key={property.id}>
{/* <PropertyCard property={property} /> */}
<PropertyDetailCard
property={property}
isPublicPage={false}
onSave={handleSaveProperty}
isOwnerView={true}
onDelete={handleDeleteProperty}
/>
</Grid>
))}
</Grid>
)}
<AddPropertyDialog
open={openAddPropertyDialog}
onClose={handleCloseAddPropertyDialog}
onAddProperty={handleAddProperty}
/>
</Container>
);
}
};
export default PropertyOwnerProfile;

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Button,
List,
ListItem,
ListItemText,
IconButton,
TextField,
Box,
Chip,
Alert,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
interface ServicesCardProps {
services: string[];
onSave: (updatedServices: string[]) => void;
serviceAreas: string[];
onSaveServiceAreas: (updatedServiceAreas: string[]) => void;
}
const ServicesCard: React.FC<ServicesCardProps> = ({
services,
onSave,
serviceAreas,
onSaveServiceAreas,
}) => {
const [isEditingServices, setIsEditingServices] = useState(false);
const [newService, setNewService] = useState('');
const [editedServices, setEditedServices] = useState<string[]>(services);
const [serviceError, setServiceError] = useState('');
const [isEditingServiceAreas, setIsEditingServiceAreas] = useState(false);
const [newServiceArea, setNewServiceArea] = useState('');
const [editedServiceAreas, setEditedServiceAreas] = useState<string[]>(serviceAreas);
const [serviceAreaError, setServiceAreaError] = useState('');
console.log(services);
// Services Handlers
const handleEditServicesToggle = () => {
if (isEditingServices) {
setEditedServices(services); // Revert on cancel
setNewService('');
setServiceError('');
}
setIsEditingServices(!isEditingServices);
};
const handleAddService = () => {
if (newService.trim() && !editedServices.includes(newService.trim())) {
setEditedServices((prev) => [...prev, newService.trim()]);
setNewService('');
setServiceError('');
} else if (editedServices.includes(newService.trim())) {
setServiceError('Service already exists.');
} else {
setServiceError('Service cannot be empty.');
}
};
const handleDeleteService = (serviceToDelete: string) => {
setEditedServices((prev) => prev.filter((s) => s !== serviceToDelete));
};
const handleSaveServices = () => {
onSave(editedServices);
setIsEditingServices(false);
};
// Service Areas Handlers
const handleEditServiceAreasToggle = () => {
if (isEditingServiceAreas) {
setEditedServiceAreas(serviceAreas); // Revert on cancel
setNewServiceArea('');
setServiceAreaError('');
}
setIsEditingServiceAreas(!isEditingServiceAreas);
};
const handleAddServiceArea = () => {
if (newServiceArea.trim() && !editedServiceAreas.includes(newServiceArea.trim())) {
setEditedServiceAreas((prev) => [...prev, newServiceArea.trim()]);
setNewServiceArea('');
setServiceAreaError('');
} else if (editedServiceAreas.includes(newServiceArea.trim())) {
setServiceAreaError('Service area already exists.');
} else {
setServiceAreaError('Service area cannot be empty.');
}
};
const handleDeleteServiceArea = (areaToDelete: string) => {
setEditedServiceAreas((prev) => prev.filter((a) => a !== areaToDelete));
};
const handleSaveServiceAreas = () => {
onSaveServiceAreas(editedServiceAreas);
setIsEditingServiceAreas(false);
};
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
{/* Services Section */}
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">Services Provided</Typography>
{isEditingServices ? (
<Box>
<IconButton color="primary" onClick={handleSaveServices}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditServicesToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditServicesToggle}>
<EditIcon />
</IconButton>
)}
</Box>
{isEditingServices ? (
<Box sx={{ mb: 2 }}>
<TextField
fullWidth
label="Add New Service"
value={newService}
onChange={(e) => {
setNewService(e.target.value);
if (serviceError) setServiceError('');
}}
onKeyPress={(e) => {
if (e.key === 'Enter') handleAddService();
}}
error={!!serviceError}
helperText={serviceError}
/>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={handleAddService}
sx={{ mt: 1 }}
>
Add Service
</Button>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{editedServices.map((service, index) => (
<Chip
key={index}
label={service}
onDelete={() => handleDeleteService(service)}
color="primary"
variant="outlined"
/>
))}
</Box>
</Box>
) : (
<List dense>
{services.length === 0 ? (
<Typography variant="body2" color="textSecondary">
No services listed.
</Typography>
) : (
services.map((service, index) => (
<ListItem key={index}>
<ListItemText primary={service} />
</ListItem>
))
)}
</List>
)}
{/* Service Areas Section */}
<Box display="flex" justifyContent="space-between" alignItems="center" mt={4} mb={2}>
<Typography variant="h6">Service Areas</Typography>
{isEditingServiceAreas ? (
<Box>
<IconButton color="primary" onClick={handleSaveServiceAreas}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditServiceAreasToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditServiceAreasToggle}>
<EditIcon />
</IconButton>
)}
</Box>
{isEditingServiceAreas ? (
<Box sx={{ mb: 2 }}>
<TextField
fullWidth
label="Add New Service Area (e.g., City, Zip Code, Neighborhood)"
value={newServiceArea}
onChange={(e) => {
setNewServiceArea(e.target.value);
if (serviceAreaError) setServiceAreaError('');
}}
onKeyPress={(e) => {
if (e.key === 'Enter') handleAddServiceArea();
}}
error={!!serviceAreaError}
helperText={serviceAreaError}
/>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={handleAddServiceArea}
sx={{ mt: 1 }}
>
Add Service Area
</Button>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{editedServiceAreas.map((area, index) => (
<Chip
key={index}
label={area}
onDelete={() => handleDeleteServiceArea(area)}
color="secondary"
variant="outlined"
/>
))}
</Box>
</Box>
) : (
<List dense>
{serviceAreas.length === 0 ? (
<Typography variant="body2" color="textSecondary">
No service areas listed.
</Typography>
) : (
serviceAreas.map((area, index) => (
<ListItem key={index}>
<ListItemText primary={area} />
</ListItem>
))
)}
</List>
)}
</CardContent>
</Card>
);
};
export default ServicesCard;

View File

@@ -0,0 +1,183 @@
import { ReactElement, useContext, useEffect, useState } from 'react';
import { UserAPI, VendorAPI } from 'types';
import {
Container,
Typography,
Box,
Divider,
Alert,
Avatar,
Rating,
Grid,
Paper,
} from '@mui/material';
import VendorProfileCard from './VendorProfileCard';
import ServicesCard from './ServiceCard';
import { axiosInstance } from '../../../../../axiosApi';
import DashboardLoading from '../Dashboard/DashboardLoading';
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import { ProfileProps } from 'pages/Profile/Profile';
import { useNavigate } from 'react-router-dom';
const VendorProfile = ({ account }: ProfileProps): ReactElement => {
const [vendor, setVendor] = useState<VendorAPI | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate();
useEffect(() => {
const fetchVendor = async () => {
try {
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
if (data.length > 0) {
setVendor(data[0]);
}
} catch (error) {
console.log(error);
} finally {
setLoadingData(false);
}
};
fetchVendor();
}, []);
if (loadingData) {
return <DashboardLoading />;
}
if (vendor === null) {
return <DashboardErrorPage />;
} else {
const handleUpgradeSubscription = () => {
navigate('/upgrade/');
// setVendor((prev) => ({
// ...prev,
// user: { ...prev.user, tier: 'premium' },
// }));
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
// setTimeout(() => setMessage(null), 3000);
};
const handleSaveVendorProfile = (updatedVendor: VendorAPI) => {
try {
let newUpdatedVendor;
// if the email is the same, remove it
if (updatedVendor.user.email === account.email) {
const { user, ...reducedVendor } = updatedVendor;
const { email, ...reducedUser } = user;
newUpdatedVendor = {
user: {
profile_created: true,
...reducedUser,
},
...reducedVendor,
};
} else {
newUpdatedVendor = updatedVendor;
}
newUpdatedVendor.user.profile_created = true;
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
...newUpdatedVendor,
});
setVendor(updatedVendor);
setMessage({ type: 'success', text: 'Vendor profile updated successfully!' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
console.log(error);
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleSaveServices = (updatedServices: string[]) => {
try {
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
services: updatedServices,
});
setVendor((prev) => ({
...prev,
services: updatedServices,
}));
setMessage({ type: 'success', text: 'Services updated successfully!' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error saving services. Try again.' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleSaveServiceAreas = (updatedServiceAreas: string[]) => {
try {
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
service_areas: updatedServiceAreas,
});
setVendor((prev) => ({
...prev,
service_areas: updatedServiceAreas,
}));
setMessage({ type: 'success', text: 'Service areas updated successfully!' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error saving service area. Try again.' });
setTimeout(() => setMessage(null), 3000);
}
};
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
Vendor Profile Dashboard
</Typography>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
<Paper sx={{ p: 3, mb: 3, display: 'flex', alignItems: 'center', gap: 3 }}>
{vendor.profile_picture && (
<Avatar
src={vendor.profile_picture}
alt={vendor.business_name}
sx={{ width: 80, height: 80 }}
/>
)}
<Box>
<Typography variant="h5">{vendor.business_name}</Typography>
<Typography variant="body1" color="textSecondary">
{vendor.business_type}
</Typography>
{vendor.average_rating !== undefined && vendor.num_reviews !== undefined && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Rating value={vendor.average_rating} precision={0.1} readOnly />
<Typography variant="body2" color="textSecondary" sx={{ ml: 1 }}>
({vendor.num_reviews} reviews)
</Typography>
</Box>
)}
</Box>
</Paper>
<VendorProfileCard
vendor={vendor}
onUpgrade={handleUpgradeSubscription}
onSave={handleSaveVendorProfile}
/>
<Divider sx={{ my: 4 }} />
<ServicesCard
services={vendor.services}
onSave={handleSaveServices}
serviceAreas={vendor.service_areas}
onSaveServiceAreas={handleSaveServiceAreas}
/>
{/* You can add more sections here, e.g., for portfolio, reviews, etc. */}
</Container>
);
}
};
export default VendorProfile;

View File

@@ -0,0 +1,416 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
CircularProgress,
Typography,
Button,
Grid,
TextField,
Box,
Alert,
IconButton,
InputLabel,
Select,
MenuItem,
FormControl,
Autocomplete,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import { VendorAPI, UserAPI } from '../types/api';
import { test_autocomplete } from 'data/mock_autocomplete_results';
import { AutocompleteDataResponseAPI } from 'types';
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
import { extractLatLon } from 'utils';
import { PlacePrediction } from './AddPropertyDialog';
interface VendorProfileCardProps {
vendor: VendorAPI;
onUpgrade: () => void;
onSave: (updatedVendor: VendorAPI) => void;
}
const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade, onSave }) => {
const [isEditing, setIsEditing] = useState(false);
const [editedVendor, setEditedVendor] = useState<VendorAPI>(vendor);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
const handleEditToggle = () => {
if (isEditing) {
setEditedVendor(vendor); // Revert on cancel
setFormErrors({});
}
setIsEditing(!isEditing);
};
const handleChange = (
e:
| React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
| React.ChangeEvent<{ name?: string; value: unknown }>,
) => {
const { name, value } = e.target;
setEditedVendor((prev) => ({
...prev,
[name as string]: value,
// Handle nested user properties if you allow editing them here
user:
name === 'email' || name === 'first_name' || name === 'last_name'
? { ...prev.user, [name]: value }
: prev.user,
}));
if (formErrors[name as string]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name as string];
return newErrors;
});
}
};
const validateForm = () => {
const errors: { [key: string]: string } = {};
if (!editedVendor.business_name.trim()) {
errors.business_name = 'Business name is required.';
}
if (!editedVendor.phone_number.trim()) {
errors.phone_number = 'Phone number is required.';
} else if (!/^\d{10}$/.test(editedVendor.phone_number.replace(/\D/g, ''))) {
errors.phone_number = 'Invalid phone number format (10 digits).';
}
if (!editedVendor.address.trim()) {
errors.address = 'Address is required.';
}
if (!editedVendor.city.trim()) {
errors.city = 'City is required.';
}
if (!editedVendor.state.trim()) {
errors.state = 'State is required.';
}
if (!editedVendor.zip_code.trim()) {
errors.zip_code = 'Zip code is required.';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave(editedVendor);
setIsEditing(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
//setNewProperty((prev) => ({ ...prev, [name]: value }));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleAddressAutocompleteInputChange = async (
event: React.SyntheticEvent,
value: string,
) => {
const test: boolean = true;
let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) {
if (test) {
data = test_autocomplete.data.filter((item) => item.address.includes(value));
// filter the data here
} else {
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
'AutoComplete',
{
search: value,
},
);
}
setAutocompleteOptions(
data.map((item) => ({
description: item.address,
place_id: item.id,
})),
);
} else {
console.log('we need more characters');
}
};
const handleAddressAutocompleteChange = async (
event: React.SyntheticEvent,
value: PlacePrediction | null,
) => {
console.log('here we go', value);
if (1) {
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
if (data.length > 0) {
const item = data[0];
const coordinates = extractLatLon(item.location);
setEditedVendor((prev) => ({
...prev,
address: item.address,
city: item.city,
state: item.state,
zip_code: item.zip,
latitude: Number(coordinates.latitude),
longitude: Number(coordinates.longitude),
}));
}
} else {
// use the api here
}
};
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h5" gutterBottom>
Vendor Profile Information
</Typography>
{isEditing ? (
<Box>
<IconButton color="primary" onClick={handleSave}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditToggle}>
<EditIcon />
</IconButton>
)}
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedVendor.user.first_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedVendor.user.last_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedVendor.user.email}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Business Name"
name="business_name"
value={editedVendor.business_name}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.business_name}
helperText={formErrors.business_name}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth disabled={!isEditing}>
<InputLabel id="business-type-label">Business Type</InputLabel>
<Select
labelId="business-type-label"
id="business_type"
name="business_type"
value={editedVendor.business_type}
label="Business Type"
onChange={handleChange}
>
<MenuItem value="electrician">Electrician</MenuItem>
<MenuItem value="carpenter">Carpenter</MenuItem>
<MenuItem value="plumber">Plumber</MenuItem>
<MenuItem value="inspector">Inspector</MenuItem>
<MenuItem value="lendor">Lendor</MenuItem>
<MenuItem value="other">Other</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Phone Number"
name="phone_number"
value={editedVendor.phone_number}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.phone_number}
helperText={formErrors.phone_number}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={autocompleteOptions}
getOptionLabel={(option) => option.description}
inputValue={editedVendor.adress}
noOptionsText={'Type at least 3 characters'}
onInputChange={handleAddressAutocompleteInputChange}
onChange={handleAddressAutocompleteChange}
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
loading={autocompleteLoading}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label="Address"
name="address"
required
error={!!formErrors.address}
helperText={formErrors.address}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{autocompleteLoading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="City"
name="city"
value={editedVendor.city}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="State"
name="state"
value={editedVendor.state}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Zip Code"
name="zip_code"
value={editedVendor.zip_code}
onChange={handleChange}
disabled={!isEditing}
required
error={!!formErrors.zip_code}
helperText={formErrors.zip_code}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Business Description"
name="description"
multiline
rows={3}
value={editedVendor.description}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Website URL"
name="website"
value={editedVendor.website || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Certifications (comma-separated)"
name="certifications"
value={editedVendor.certifications?.join(', ') || ''}
onChange={(e) =>
setEditedVendor((prev) => ({
...prev,
certifications: e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s),
}))
}
disabled={!isEditing}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle1">
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography>
{vendor.user.tier === 'basic' && (
<Button
variant="contained"
color="primary"
onClick={onUpgrade}
sx={{ mt: 1 }}
disabled={isEditing}
>
Upgrade to Premium
</Button>
)}
</Grid>
</Grid>
{Object.keys(formErrors).length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
Please correct the errors in the form.
</Alert>
)}
</CardContent>
</Card>
);
};
export default VendorProfileCard;

View File

@@ -0,0 +1,474 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Grid,
Box,
ImageList,
ImageListItem,
ImageListItemBar,
Button,
TextField,
Alert,
IconButton,
Stack,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import { axiosInstance } from '../../../../../axiosApi';
import { PropertiesAPI } from 'types';
import MapComponent from '../../../../base/MapComponent';
import FormattedListingText from 'components/base/FormattedListingText';
import { useNavigate } from 'react-router-dom';
interface PropertyDetailCardProps {
property: PropertiesAPI;
onSave: (updatedProperty: PropertiesAPI) => void;
isPublicPage?: boolean;
isOwnerView?: boolean; // True if the current user is the owner, allows editing
onDelete?: (propertyId: number) => void;
}
const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
property,
onSave,
isPublicPage,
isOwnerView = false,
onDelete = null,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isGenerating, setIsGernerating] = useState<boolean>(false);
const [editedProperty, setEditedProperty] = useState<PropertiesAPI>(property);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
// Ensure latitude and longitude are defined, use defaults if not available
const displayLat = Number(editedProperty.latitude) ?? 34.0522; // Default to LA for demo
const displayLng = Number(editedProperty.longitude) ?? -118.2437; // Default to LA for demo
const navigate = useNavigate();
const handleEditToggle = () => {
if (isEditing) {
setEditedProperty(property); // Revert changes on cancel
setFormErrors({});
}
setIsEditing(!isEditing);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setEditedProperty((prev) => ({
...prev,
[name]: value,
}));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const numValue = parseFloat(value);
setEditedProperty((prev) => ({
...prev,
[name]: isNaN(numValue) ? '' : numValue,
}));
if (formErrors[name]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleFeaturesChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setEditedProperty((prev) => ({
...prev,
features: value
.split(',')
.map((f) => f.trim())
.filter((f) => f),
}));
};
const handleViewPublicListing = () => {
navigate(`/property/${property.id}/`);
};
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
// In a real app, you'd upload these files to a server and get URLs back
console.log(filesArray);
filesArray.map((file) => {
const formData = new FormData();
formData.append('image', file);
formData.append('Property', property.id.toString());
const response = axiosInstance.post('/properties/pictures/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log(response);
});
}
};
const validateForm = () => {
const errors: { [key: string]: string } = {};
if (!editedProperty.address.trim()) errors.address = 'Address is required.';
if (!editedProperty.city.trim()) errors.city = 'City is required.';
if (!editedProperty.state.trim()) errors.state = 'State is required.';
if (!editedProperty.zip_code.trim()) errors.zip_code = 'Zip code is required.';
if (editedProperty.sq_ft !== undefined && editedProperty.sq_ft <= 0)
errors.sq_ft = 'Square footage must be positive.';
if (editedProperty.num_bedrooms !== undefined && editedProperty.num_bedrooms < 0)
errors.num_bedrooms = 'Bedrooms cannot be negative.';
if (editedProperty.num_bathrooms !== undefined && editedProperty.num_bathrooms < 0)
errors.num_bathrooms = 'Bathrooms cannot be negative.';
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave(editedProperty);
setIsEditing(false);
}
};
const handleDelete = () => {
if (isOwnerView && !isPublicPage && onDelete) {
onDelete(property.id);
}
};
const generateDescription = async () => {
setIsGernerating(true);
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
console.log(response);
setIsGernerating(false);
// TODO: toggle the update
};
return (
<Card sx={{ mt: 3, p: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h5" component="h2">
{isEditing ? 'Edit Property Details' : property.address}
</Typography>
{isOwnerView &&
!isPublicPage &&
(isEditing ? (
<Box>
<IconButton color="primary" onClick={handleDelete}>
<DeleteForeverIcon />
</IconButton>
<IconButton color="primary" onClick={handleSave}>
<SaveIcon />
</IconButton>
<IconButton color="secondary" onClick={handleEditToggle}>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton color="primary" onClick={handleEditToggle}>
<EditIcon />
</IconButton>
))}
</Box>
<Grid container spacing={3}>
{/* Property Address & Basic Info */}
<Grid item xs={12}>
{isEditing ? (
<Grid container spacing={2}>
<Grid item xs={8}>
<TextField
fullWidth
label="Address"
name="address"
value={editedProperty.address}
onChange={handleChange}
required
error={!!formErrors.address}
helperText={formErrors.address}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="City"
name="city"
value={editedProperty.city}
onChange={handleChange}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="State"
name="state"
value={editedProperty.state}
onChange={handleChange}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Zip Code"
name="zip_code"
value={editedProperty.zip_code}
onChange={handleChange}
required
error={!!formErrors.zip_code}
helperText={formErrors.zip_code}
/>
</Grid>
</Grid>
) : (
<Stack direction="row">
<Typography variant="h6">
{property.street}, {property.city}, {property.state} {property.zip_code}
</Typography>
{isOwnerView && !isPublicPage && (
<Button onClick={handleViewPublicListing}>View Public Listing</Button>
)}
</Stack>
)}
</Grid>
{/* Pictures */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
Pictures:
</Typography>
{editedProperty.pictures && editedProperty.pictures.length > 0 ? (
<ImageList
cols={editedProperty.pictures.length > 1 ? 2 : 1}
rowHeight={164}
sx={{ maxWidth: 500 }}
>
{editedProperty.pictures.map((item, index) => (
<ImageListItem key={item.id}>
<img
srcSet={`${item.image}?w=164&h=164&fit=crop&auto=format 1x, ${item.image}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
src={`${item.image}?w=164&h=164&fit=crop&auto=format`}
alt={`Property image ${index + 1}`}
loading="lazy"
/>
<ImageListItemBar title={`Image ${index + 1}`} />
</ImageListItem>
))}
</ImageList>
) : (
<Typography variant="body2" color="textSecondary">
No pictures available.
</Typography>
)}
{isEditing && (
<Button
variant="outlined"
component="label"
startIcon={<AddPhotoAlternateIcon />}
sx={{ mt: 2 }}
>
Add More Pictures
<input
type="file"
hidden
multiple
accept="image/*"
onChange={handlePictureUpload}
/>
</Button>
)}
</Grid>
{/* Description & Stats */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
Description:
</Typography>
{isEditing ? (
<Stack direction="column">
<TextField
fullWidth
multiline
rows={4}
label="Description"
name="description"
value={editedProperty.description}
onChange={handleChange}
/>
<Button variant="contained" onClick={generateDescription} disabled={isGenerating}>
Generate a description
</Button>
</Stack>
) : (
<FormattedListingText text={property.description} />
// <Typography variant="body2" color="textSecondary">
// {property.description || 'No description provided.'}
// </Typography>
)}
<Typography variant="subtitle1" sx={{ mt: 2 }} gutterBottom>
Stats:
</Typography>
{isEditing ? (
<Grid container spacing={2}>
<Grid item xs={6}>
<TextField
fullWidth
label="Sq Ft"
name="sq_ft"
type="number"
value={editedProperty.sq_ft || ''}
onChange={handleNumericChange}
error={!!formErrors.sq_ft}
helperText={formErrors.sq_ft}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label="Bedrooms"
name="num_bedrooms"
type="number"
value={editedProperty.num_bedrooms || ''}
onChange={handleNumericChange}
error={!!formErrors.num_bedrooms}
helperText={formErrors.num_bedrooms}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label="Bathrooms"
name="num_bathrooms"
type="number"
value={editedProperty.num_bathrooms || ''}
onChange={handleNumericChange}
error={!!formErrors.num_bathrooms}
helperText={formErrors.num_bathrooms}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Features (comma-separated)"
name="features"
value={editedProperty.features.join(', ') || ''}
onChange={handleFeaturesChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Market Value"
name="market_value"
value={editedProperty.market_value}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Loan Amount"
name="loan_amount"
value={editedProperty.loan_amount}
onChange={handleChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Loan Term (years)"
name="loan_term"
type="number"
value={editedProperty.loan_term || ''}
onChange={handleNumericChange}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Loan Start Date"
name="loan_start_date"
type="date"
InputLabelProps={{ shrink: true }}
value={editedProperty.loan_start_date}
onChange={handleChange}
/>
</Grid>
</Grid>
) : (
<Box>
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
<Typography variant="body2">
Bathrooms: {property.num_bathrooms || 'N/A'}
</Typography>
<Typography variant="body2">
Features:{' '}
{property.features && property.features.length > 0
? property.features.join(', ')
: 'None'}
</Typography>
<Typography variant="body2">
Market Value: ${property.market_value || 'N/A'}
</Typography>
<Typography variant="body2">
Loan Amount: ${property.loan_amount || 'N/A'}
</Typography>
<Typography variant="body2">
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
</Typography>
<Typography variant="body2">
Loan Start Date: {property.loan_start_date || 'N/A'}
</Typography>
</Box>
)}
</Grid>
{/* Map */}
<Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
Location on Map:
</Typography>
<MapComponent lat={displayLat} lng={displayLng} address={property.address} />
</Grid>
</Grid>
{Object.keys(formErrors).length > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
Please correct the errors in the form.
</Alert>
)}
</CardContent>
</Card>
);
};
export default PropertyDetailCard;

View File

@@ -1,80 +0,0 @@
import { ReactElement } from 'react';
import { Card, CardContent, CardMedia, Divider, Stack, Typography } from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';
const PropertyDetailsCard = (): ReactElement => {
return(
<Card
sx={(theme) => ({
boxShadow: theme.shadows[4],
width: 1,
height: 'auto',
})}
>
<CardContent
sx={{
flex: '1 1 auto',
padding: 0,
':last-child': {
paddingBottom: 0,
},
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
<Typography variant="subtitle1" component="h3" minWidth={100} color="text.primary">
Property Details
</Typography>
</Stack>
<Grid
container
>
<Grid xs={6}>
<Typography>
Property Type: Single Family Home
</Typography>
</Grid>
<Grid xs={6}>
<Typography>
Year Built: 1998
</Typography>
</Grid>
<Grid xs={6}>
<Typography>
Lot Size: 0.25 acres
</Typography>
</Grid>
<Grid xs={6}>
<Typography>
Bedrooms: 3
</Typography>
</Grid>
<Grid xs={6}>
<Typography>
Bathrooms: 2
</Typography>
</Grid>
<Grid xs={6}>
<Typography>
Square Feet: 1,850
</Typography>
</Grid>
</Grid>
<Divider />
<Typography>
Beautifully maintained home in desirable neighborhood. Features updated kitchen with granite countertops, hardwood floors throughout main living areas, spacious master suite, and large backyard with deck. Excellent schools nearby.
</Typography>
</CardContent>
</Card>
)
}
export default PropertyDetailsCard;

View File

@@ -1,22 +1,27 @@
import { ReactElement } from 'react';
import { Card, CardContent, CardMedia, Stack, Typography } from '@mui/material';
import { Button, Card, CardActions, CardContent, CardMedia, Stack, Typography } from '@mui/material';
import { PropertiesAPI } from 'types';
import { Navigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
type EducationInfoProps = {
title: string;
property: PropertiesAPI;
}
export const ProperyInfoCards = () => {
export const ProperyInfoCards = ({ property }: EducationInfoProps) => {
return(
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
<PropertyInfo title={'1968 Greensboro Dr'} />
<PropertyInfo property={property} />
</Stack>
)
}
const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
const PropertyInfo = ({ property }: EducationInfoProps): ReactElement => {
const navigate = useNavigate();
const estimated_savings = Number(property.market_value) * 0.06 - 6000
return(
<Card
sx={(theme) => ({
@@ -25,7 +30,38 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
height: 'auto',
})}
>
<CardContent
<CardMedia
sx={{ height: 140 }}
image="https://saterdesign.com/cdn/shop/files/9024-Main-Image.jpg"
title="green iguana"
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{property.address}
</Typography>
<Typography gutterBottom variant="h5" component="div">
${property.market_value}
</Typography>
<Stack direction='row'>
<Typography variant='caption'>
3 bd | 2 ba | 1,860 sqtf
</Typography>
<Typography variant='caption'>
Listed 14 days ago
</Typography>
</Stack>
</CardContent>
<CardActions>
<Button size="small"
variant="contained"
component="label"
onClick={() => navigate('/property')}>View</Button>
</CardActions>
{/* <CardContent
sx={{
flex: '1 1 auto',
padding: 0,
@@ -36,16 +72,16 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
{title}
{property.address}
</Typography>
</Stack>
<Stack direction="column" justifyContent="space-between" flexWrap="wrap">
<Typography>
Estimated Home Value: <b>$700,500k</b>
Estimated Home Value: <b>${property.market_value}</b>
</Typography>
<Typography>
Estimated Savings: $24,000k
Estimated Savings: ${estimated_savings}
</Typography>
<Typography>
Compariable Time on market: 5 days
@@ -57,7 +93,7 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
</CardContent>
</CardContent> */}
</Card>

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { Card, CardContent, Typography, Button, Box, CardMedia } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { PropertiesAPI } from 'types';
interface PropertyListItemProps {
property: PropertiesAPI;
onViewDetails: (propertyId: number) => void; // For navigation in search page
}
const PropertyListItem: React.FC<PropertyListItemProps> = ({ property, onViewDetails }) => {
const navigate = useNavigate();
const handleViewDetailsClick = () => {
// Navigate to the full detail page for this property
navigate(`/property/${property.id}/?search=1`);
};
const value_price = property.listed_price ? property.listed_price : property.market_value;
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';
return (
<Card
sx={{ display: 'flex', mb: 2, '&:hover': { boxShadow: 6 }, cursor: 'pointer' }}
onClick={handleViewDetailsClick}
>
{property.pictures && property.pictures.length > 0 && (
<CardMedia
component="img"
sx={{ width: 150, height: 150, flexShrink: 0, objectFit: 'cover' }}
image={property.pictures[0]}
alt={property.address}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardContent sx={{ flex: '1 0 auto' }}>
<Typography component="div" variant="h6">
{property.address}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{property.city}, {property.state} {property.zip_code}
</Typography>
<Typography variant="body2" color="text.secondary">
{property.num_bedrooms} Beds | {property.num_bathrooms} Baths | {property.sq_ft} Sq Ft
</Typography>
<Typography variant="body1" sx={{ mt: 1 }}>
{value_text}: <strong>${value_price}</strong>
</Typography>
</CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
pl: 1,
pb: 1,
pr: 2,
justifyContent: 'flex-end',
}}
>
<Button size="small" onClick={handleViewDetailsClick}>
View Details
</Button>
</Box>
</Box>
</Card>
);
};
export default PropertyListItem;

View File

@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Grid, Paper, Typography } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
interface SearchFilters {
address: string;
city: string;
state: string;
zipCode: string;
minSqFt: number | '';
maxSqFt: number | '';
minBedrooms: number | '';
maxBedrooms: number | '';
minBathrooms: number | '';
maxBathrooms: number | '';
}
interface PropertySearchFiltersProps {
onSearch: (filters: SearchFilters) => void;
onClear: () => void;
}
const initialFilters: SearchFilters = {
address: '',
city: '',
state: '',
zipCode: '',
minSqFt: '',
maxSqFt: '',
minBedrooms: '',
maxBedrooms: '',
minBathrooms: '',
maxBathrooms: '',
};
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value }));
};
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const numValue = value === '' ? '' : parseFloat(value);
setFilters(prev => ({ ...prev, [name]: numValue }));
};
const handleSearchClick = () => {
onSearch(filters);
};
const handleClearClick = () => {
setFilters(initialFilters);
onClear();
};
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>Search Properties</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<TextField fullWidth label="Address Keyword" name="address" value={filters.address} onChange={handleChange} />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField fullWidth label="City" name="city" value={filters.city} onChange={handleChange} />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField fullWidth label="State" name="state" value={filters.state} onChange={handleChange} />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<TextField fullWidth label="Zip Code" name="zipCode" value={filters.zipCode} onChange={handleChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Min Sq Ft" name="minSqFt" type="number" value={filters.minSqFt} onChange={handleNumericChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Max Sq Ft" name="maxSqFt" type="number" value={filters.maxSqFt} onChange={handleNumericChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Min Bedrooms" name="minBedrooms" type="number" value={filters.minBedrooms} onChange={handleNumericChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Max Bedrooms" name="maxBedrooms" type="number" value={filters.maxBedrooms} onChange={handleNumericChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Min Bathrooms" name="minBathrooms" type="number" value={filters.minBathrooms} onChange={handleNumericChange} />
</Grid>
<Grid item xs={6} sm={3}>
<TextField fullWidth label="Max Bathrooms" name="maxBathrooms" type="number" value={filters.maxBathrooms} onChange={handleNumericChange} />
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
Clear Filters
</Button>
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
Search
</Button>
</Grid>
</Grid>
</Paper>
);
};
export default PropertySearchFilters;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Chip,
Button,
IconButton,
Select,
MenuItem,
} from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import FavoriteIcon from '@mui/icons-material/Favorite';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { PropertiesAPI } from 'types';
interface PropertyStatusCardProps {
property: PropertiesAPI;
isOwner: boolean;
onStatusChange?: () => void;
onSavedPropertySave?: () => void;
}
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
property,
isOwner,
onStatusChange,
onSavedPropertySave,
}) => {
const getStatusColor = (status: PropertiesAPI['property_status']) => {
switch (status) {
case 'active':
return 'success';
case 'pending':
return 'warning';
case 'contingent':
return 'primary';
case 'sold':
return 'secondary';
default:
return 'default';
}
};
const timeSinceListed = (dateString: string) => {
const listedDate = new Date(dateString);
const now = new Date();
const diffInMs = now.getTime() - listedDate.getTime();
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
return `${diffInDays} days`;
};
const getTimeOnMarketString = (status: PropertiesAPI['property_status'], listed_date: string) => {
switch (status) {
case 'active':
return timeSinceListed(listed_date) + ' On Market';
case 'pending':
return timeSinceListed(listed_date) + ' On Market';
case 'contingent':
return timeSinceListed(listed_date) + ' On Market';
case 'sold':
return '';
default:
return '';
}
};
const timeOnMarketString: string = getTimeOnMarketString(
property.property_status,
property?.listed_date ? property?.listed_date : '',
);
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Property Status
</Typography>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
${property.listed_price ? property.listed_price : property.market_value}
</Typography>
{isOwner ? (
<Select
value={property.property_status}
onChange={onStatusChange}
displayEmpty
variant="standard"
sx={{ mt: 2 }}
>
<MenuItem value="off_market" disabled={property.property_status === 'off_market'}>
Off Market
</MenuItem>
<MenuItem value="active" disabled={property.property_status === 'active'}>
Active
</MenuItem>
</Select>
) : (
<Chip
label={property.property_status.toUpperCase()}
color={getStatusColor(property.property_status)}
sx={{ fontSize: '1rem', fontWeight: 'bold' }}
/>
)}
</Box>
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
<Box display="flex" alignItems="center">
<VisibilityIcon color="action" sx={{ mr: 1 }} />
<Typography variant="body1">{property.views} Views</Typography>
</Box>
<Box display="flex" alignItems="center">
{isOwner ? (
<FavoriteIcon color="action" sx={{ mr: 1 }} />
) : (
<FavoriteIcon color="action" sx={{ mr: 1 }} onClick={onSavedPropertySave} />
)}
<Typography variant="body1">{property.saves} Saves</Typography>
</Box>
{timeOnMarketString && (
<Box display="flex" alignItems="center">
<AccessTimeIcon color="action" sx={{ mr: 1 }} />
<Typography variant="body1">{timeOnMarketString}</Typography>
</Box>
)}
</Box>
</CardContent>
</Card>
);
};
export default PropertyStatusCard;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@mui/material';
import { SaleHistoryAPI, TaxHistoryAPI } from 'types';
interface SaleTaxHistoryCardProps {
saleHistory?: SaleHistoryAPI[];
taxInfo: TaxHistoryAPI;
}
const SaleTaxHistoryCard: React.FC<SaleTaxHistoryCardProps> = ({ saleHistory, taxInfo }) => {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Sale & Tax History
</Typography>
{/* Sale History Table */}
<Box mb={4}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 1 }}>
Sale History
</Typography>
{saleHistory && saleHistory.length > 0 ? (
<TableContainer component={Paper} sx={{ boxShadow: 0 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Sale Date</TableCell>
<TableCell>Sale Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{saleHistory.map((item) => (
<TableRow key={item.seq_no}>
<TableCell>{item.sale_date}</TableCell>
<TableCell>${item.sale_amount.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="text.secondary">
No recent sale history available.
</Typography>
)}
</Box>
{/* Tax History Table */}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 1 }}>
Latest Tax Info
</Typography>
{taxInfo ? (
<TableContainer component={Paper} sx={{ boxShadow: 0 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Year</TableCell>
<TableCell>Assessed Value</TableCell>
<TableCell>Tax Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>{taxInfo.year}</TableCell>
<TableCell>${taxInfo.assessed_value.toLocaleString()}</TableCell>
<TableCell>${taxInfo.tax_amount.toLocaleString()}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="text.secondary">
No tax information available.
</Typography>
)}
</Box>
</CardContent>
</Card>
);
};
export default SaleTaxHistoryCard;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemText,
Divider,
Box,
Chip,
} from '@mui/material';
import SchoolIcon from '@mui/icons-material/School';
import StarRateIcon from '@mui/icons-material/StarRate';
import { SchoolAPI } from 'types';
interface SchoolCardProps {
schools: SchoolAPI[];
}
const SchoolCard: React.FC<SchoolCardProps> = ({ schools }) => {
return (
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
<SchoolIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Nearby Schools</Typography>
</Box>
{schools.length > 0 ? (
<List dense>
{schools.map((school, index) => (
<React.Fragment key={index}>
<ListItem disableGutters>
<ListItemText
primary={
<Box display="flex" alignItems="center">
<Typography variant="body1" sx={{ fontWeight: 'bold', mr: 1 }}>
{school.name}
</Typography>
<Chip
label={school.school_type.toUpperCase()}
size="small"
color={school.school_type === 'public' ? 'info' : 'default'}
/>
</Box>
}
secondary={
<Box>
<Typography component="span" variant="body2" color="text.primary">
{school.address}, {school.city}
</Typography>
<Box display="flex" alignItems="center">
<StarRateIcon fontSize="small" sx={{ color: 'gold' }} />
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ ml: 0.5 }}
>
Overall Rating: {school.rating} / 10
</Typography>
</Box>
<Typography variant="caption" display="block" color="text.secondary">
Grades: {school.grades} | Enrollment: {school.enrollment}
</Typography>
</Box>
}
/>
</ListItem>
{index < schools.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
No school information available.
</Typography>
)}
</CardContent>
</Card>
);
};
export default SchoolCard;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Link } from '@mui/material';
import DirectionsWalkIcon from '@mui/icons-material/DirectionsWalk';
import DirectionsBikeIcon from '@mui/icons-material/DirectionsBike';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import { WalkScoreAPI } from '../types/api';
interface WalkScoreCardProps {
walkScore: WalkScoreAPI | null;
}
const WalkScoreCard: React.FC<WalkScoreCardProps> = ({ walkScore }) => {
if (walkScore) {
return (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" gutterBottom>
Walk Score
</Typography>
<img src={walkScore.logo_url} alt="Walk Score Logo" style={{ height: 24 }} />
</Box>
<Box
display="flex"
justifyContent="space-around"
alignItems="center"
textAlign="center"
mt={2}
>
<Box>
<DirectionsWalkIcon color="primary" sx={{ fontSize: 40 }} />
<Typography variant="h5" sx={{ mt: 1 }}>
{walkScore.walk_score}
</Typography>
<Typography variant="caption">{walkScore.walk_description}</Typography>
</Box>
<Box>
<DirectionsBikeIcon color="primary" sx={{ fontSize: 40 }} />
<Typography variant="h5" sx={{ mt: 1 }}>
{walkScore.bike_score}
</Typography>
<Typography variant="caption">{walkScore.bike_description}</Typography>
</Box>
<Box>
<DirectionsBusIcon color="primary" sx={{ fontSize: 40 }} />
<Typography variant="h5" sx={{ mt: 1 }}>
{walkScore.transit_score}
</Typography>
<Typography variant="caption">{walkScore.transit_description}</Typography>
</Box>
</Box>
<Link
href={walkScore.ws_link}
target="_blank"
rel="noopener"
sx={{ mt: 2, display: 'block' }}
>
View on Walk Score
</Link>
</CardContent>
</Card>
);
} else {
return (
<Card>
<CardContent>
<p>Data not available at the moment</p>
</CardContent>
</Card>
);
}
};
export default WalkScoreCard;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import {
Box,
Typography,
Button,
Paper,
List,
ListItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import StarsIcon from '@mui/icons-material/Stars';
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
interface ProfessionalUpgradeProps {
userType: 'vendor' | 'attorney' | 'real_estate_agent';
onUpgradeClick: () => void;
}
const ProfessionalUpgrade: React.FC<ProfessionalUpgradeProps> = ({ userType, onUpgradeClick }) => {
const titleMap = {
vendor: 'Vendors',
attorney: 'Attorneys',
real_estate_agent: 'Real Estate Agents',
};
const title = titleMap[userType] || 'Professionals';
return (
<Paper sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" component="h2" gutterBottom color="primary">
Elevate Your Business as a Premium {title}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Upgrade to a Premium account to gain a competitive edge and grow your client base.
</Typography>
<List sx={{ textAlign: 'left', maxWidth: 600, mx: 'auto' }}>
<ListItem>
<ListItemIcon>
<StarsIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Prioritized in Search Results"
secondary="Appear higher in search rankings, increasing your visibility to potential clients."
/>
</ListItem>
<ListItem>
<ListItemIcon>
<SupportAgentIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Priority Customer Support"
secondary="Get faster assistance with any queries or issues, ensuring your operations run smoothly."
/>
</ListItem>
{/* Add more professional-specific benefits if needed */}
</List>
<Button
variant="contained"
color="primary"
size="large"
sx={{ mt: 4 }}
onClick={onUpgradeClick}
>
Upgrade to Premium
</Button>
</Paper>
);
};
export default ProfessionalUpgrade;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import {
Box,
Typography,
Button,
Paper,
List,
ListItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import HandshakeIcon from '@mui/icons-material/Handshake';
import BalanceIcon from '@mui/icons-material/Balance';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
interface PropertyOwnerUpgradeProps {
onUpgradeClick: () => void;
}
const PropertyOwnerUpgrade: React.FC<PropertyOwnerUpgradeProps> = ({ onUpgradeClick }) => {
return (
<Paper sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" component="h2" gutterBottom color="primary">
Unlock Premium for Property Owners
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Upgrade to a Premium account and gain access to exclusive tools and resources designed to
help you succeed in your real estate journey.
</Typography>
<List sx={{ textAlign: 'left', maxWidth: 600, mx: 'auto' }}>
<ListItem>
<ListItemIcon>
<VideoLibraryIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Exclusive FSBO Educational Video Library"
secondary="Learn the ins and outs of selling your home yourself with expert guidance."
/>
</ListItem>
<ListItem>
<ListItemIcon>
<HandshakeIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Direct Access to Verified Vendors"
secondary="Find and communicate directly with trusted professionals for all your property needs."
/>
</ListItem>
<ListItem>
<ListItemIcon>
<BalanceIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Dedicated Attorney Support"
secondary="Receive specialized legal guidance throughout your selling or buying process."
/>
</ListItem>
<ListItem>
<ListItemIcon>
<TipsAndUpdatesIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary="Advanced AI Tools for Listings"
secondary="Generate compelling housing descriptions and get instant answers to your real estate questions."
/>
</ListItem>
</List>
<Button
variant="contained"
color="primary"
size="large"
sx={{ mt: 4 }}
onClick={onUpgradeClick}
>
Upgrade to Premium
</Button>
</Paper>
);
};
export default PropertyOwnerUpgrade;

View File

@@ -0,0 +1,47 @@
// src/components/VendorApp/VendorCategoryCard.tsx
import React from 'react';
import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material';
import { VendorCategory } from 'types';
interface VendorCategoryCardProps {
category: VendorCategory;
onSelectCategory: (categoryId: string) => void;
}
const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => {
return (
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia
component="img"
height="140"
image={category.imageUrl}
alt={category.name}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="div">
{category.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{category.description}
</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="body2">Vendors: {category.numVendors}</Typography>
{category.categoryRating && (
<Box display="flex" alignItems="center">
<Rating value={category.categoryRating} readOnly precision={0.5} size="small" />
<Typography variant="caption" sx={{ ml: 0.5 }}>({category.categoryRating.toFixed(1)})</Typography>
</Box>
)}
</Box>
</CardContent>
<Box sx={{ p: 2, pt: 0 }}>
<Button size="small" onClick={() => onSelectCategory(category.id)}>
View Vendors
</Button>
</Box>
</Card>
);
};
export default VendorCategoryCard;

View File

@@ -0,0 +1,162 @@
// src/components/VendorApp/VendorDetail.tsx
import React, { useContext, useEffect } from 'react';
import { Box, Typography, Paper, Avatar, Rating, Grid, Button, Stack } from '@mui/material';
import PhoneIcon from '@mui/icons-material/Phone';
import EmailIcon from '@mui/icons-material/Email';
import LocationOnIcon from '@mui/icons-material/LocationOn';
import { ConverationAPI, VendorItem } from 'types';
import VendorMap from './VendorMap';
import { useNavigate } from 'react-router-dom';
import { AccountContext } from 'contexts/AccountContext';
import { axiosInstance } from '../../../../../axiosApi';
import { AxiosResponse } from 'axios';
import MapComponent from 'components/base/MapComponent';
interface VendorDetailProps {
vendor: VendorItem;
showMessageBtn: boolean;
}
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
const navigate = useNavigate();
const { account, accountLoading } = useContext(AccountContext);
const createMessage = () => {
// First see if there is one already
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.get(
`/conversations/?vendor=${vendor.id}`,
);
if (data === undefined) {
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.post(`/conversations/`, {
property_owner: account?.id,
vendor: vendor.id,
});
}
navigate('/messages');
};
if (!vendor) {
return (
<Paper
elevation={2}
sx={{
p: 3,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="h6" color="text.secondary">
No vendor selected
</Typography>
</Paper>
);
}
useEffect(() => {
// increment the vendor view count
const incrementVendorCount = async () => {
if (showMessageBtn) {
try {
await axiosInstance.post(`/vendors/${vendor.id}/increment_view_count/?search=1`);
} catch (error) {}
}
};
incrementVendorCount();
}, []);
return (
<Paper elevation={3} sx={{ p: 2 }}>
<Grid container sx={{ minHeight: '100%' }}>
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
<Box display="flex" alignItems="center" mb={2}>
<Avatar
src={vendor.vendorImageUrl}
alt={vendor.name}
sx={{ width: 80, height: 80, mr: 2 }}
/>
<Box>
<Typography variant="h5" gutterBottom>
{vendor.name}
</Typography>
<Box display="flex" alignItems="center">
<Rating value={vendor.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({vendor.rating.toFixed(1)} / 5)
</Typography>
</Box>
</Box>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{vendor.description}
</Typography>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Contact Information
</Typography>
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
<PhoneIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.phone}
</Typography>
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
<EmailIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.email}
</Typography>
<Typography variant="body2" display="flex" alignItems="center">
<LocationOnIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.address}
</Typography>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Services Offered
</Typography>
<Box>
{vendor.servicesOffered.map((service, index) => (
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
- {service}
</Typography>
))}
</Box>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Service Areas
</Typography>
<Box>
{vendor.serviceAreas.map((service, index) => (
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
- {service}
</Typography>
))}
</Box>
{showMessageBtn && (
<Button onClick={createMessage} disabled={accountLoading}>
Message
</Button>
)}
</Grid>
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
<Box
sx={{
width: '100%',
height: '100%', // Make sure the box takes available height
minHeight: '400px', // Ensure a minimum height for the map
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '4px', // Optional: rounded corners
overflow: 'hidden', // Ensures map doesn't overflow border radius
}}
>
{vendor.latitude && vendor.longitude ? (
<MapComponent lat={vendor.latitude} lng={vendor.longitude} address={vendor.address} />
) : (
<Typography variant="body2" color="text.secondary">
Location data not available for this vendor.
</Typography>
)}
</Box>
</Grid>
</Grid>
</Paper>
);
};
export default VendorDetail;

View File

@@ -0,0 +1,42 @@
// src/components/VendorApp/VendorListItem.tsx
import React from 'react';
import { ListItem, ListItemAvatar, Avatar, ListItemText, Typography, Rating, Box } from '@mui/material';
import { VendorItem } from 'types';
interface VendorListItemProps {
vendor: VendorItem;
isSelected: boolean;
onSelect: (vendorId: number) => void;
}
const VendorListItem: React.FC<VendorListItemProps> = ({ vendor, isSelected, onSelect }) => {
return (
<ListItem
button
selected={isSelected}
onClick={() => onSelect(vendor.id)}
sx={{ borderBottom: '1px solid #eee' }}
>
<ListItemAvatar>
<Avatar src={vendor.vendorImageUrl} alt={vendor.name} />
</ListItemAvatar>
<ListItemText
primary={vendor.name}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{vendor.description}
</Typography>
<Box display="flex" alignItems="center">
<Rating value={vendor.rating} readOnly precision={0.5} size="small" />
<Typography variant="caption" sx={{ ml: 0.5 }}>({vendor.rating.toFixed(1)})</Typography>
</Box>
</Box>
}
/>
</ListItem>
);
};
export default VendorListItem;

View File

@@ -0,0 +1,57 @@
// components/VendorMap.tsx
import React, { FC, useMemo } from 'react';
import { GoogleMap, Marker, useLoadScript } from '@react-google-maps/api';
import { CircularProgress, Box, Typography } from '@mui/material';
interface VendorMapProps {
latitude: number;
longitude: number;
vendorName: string;
}
const mapContainerStyle = {
width: '100%',
height: '400px', // You can adjust this height as needed
};
const libraries: ('places' | 'drawing' | 'geometry' | 'localContext' | 'visualization')[] = ['places']; // 'places' is a common library to load
const VendorMap: FC<VendorMapProps> = ({ latitude, longitude, vendorName }) => {
const { isLoaded, loadError } = useLoadScript({
googleMapsApiKey: 'AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w',//process.env.REACT_APP_Maps_API_KEY!, // Replace with your actual API key environment variable
libraries: libraries,
});
const center = useMemo(() => ({
lat: latitude,
lng: longitude,
}), [latitude, longitude]);
if (loadError) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<Typography color="error">Error loading maps</Typography>
</Box>
);
}
if (!isLoaded) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<CircularProgress />
</Box>
);
}
return (
<GoogleMap
mapContainerStyle={mapContainerStyle}
center={center}
zoom={15} // Adjust zoom level as needed
>
<Marker position={center} title={vendorName} />
</GoogleMap>
);
};
export default VendorMap;

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState, createContext, useEffect, useContext } from "react"
import { AuthContext } from "./AuthContext";
import { AxiosResponse } from "axios";
import { axiosInstance } from "../axiosApi";
import { UserAPI } from "types";
type AccountProviderProps ={
children? : ReactNode;
}
type IAccountContext = {
account: UserAPI | undefined;
setAccount: (account: UserAPI | undefined) => void;
accountLoading: boolean;
}
const initialValues = {
account: undefined,
setAccount: () => {},
accountLoading: true,
}
const AccountContext = createContext<IAccountContext>(initialValues);
const AccountProvider = ({children}: AccountProviderProps) => {
const [account, setAccount] = useState<UserAPI | undefined>(initialValues.account);
const [accountLoading, setAccountLoading] = useState(true); // Add a loading state
const { authenticated, loading } = useContext(AuthContext);
async function getAccount (){
const get_user_response: AxiosResponse<UserAPI> = await axiosInstance.get('/user/')
setAccount(get_user_response.data)
}
useEffect(() => {
if(!loading && authenticated){
getAccount();
}
setAccountLoading(false);
}, [authenticated])
return (
<AccountContext.Provider value={{account, setAccount, accountLoading}}>
{children}
</AccountContext.Provider>
)
}
export { AccountContext, AccountProvider }

View File

@@ -0,0 +1,80 @@
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import { axiosInstance } from "../../axiosApi";
import { AuthContext } from "./AuthContext";
import { PreferenceContext } from "./PreferencesContext";
import { ConverationAPI } from "types";
type ConversationProviderProps ={
children? : ReactNode;
}
type IConversationContext = {
conversations: ConverationAPI[];
setConversations: (conversations: ConverationAPI[]) => void;
selectedConversation: number | undefined;
setSelectedConversation: (conversation_id: number | undefined) => void;
deleteConversation: (conversation_id: number | undefined) => void;
}
const initialValues = {
conversations: [],
setConversations: () => {},
selectedConversation: undefined,
setSelectedConversation: () => {},
deleteConversation: () => {},
}
const ConversationContext = createContext<IConversationContext>(initialValues);
const ConversationProvider = ({children}: ConversationProviderProps) => {
const [conversations, setConversations] = useState<ConverationAPI[]>([]);
const [selectedConversation, setSelectedConversation] = useState<number | undefined>(undefined);
const { authenticated, loading } = useContext(AuthContext);
const {preferencesUpdated} = useContext(PreferenceContext);
// function deleteConversation(conversation_id: number | undefined){
// //console.log(`detele ${conversation_id}`)
// try{
// axiosInstance.delete(`conversation_details`, {
// data: {'conversation_id':conversation_id}
// })
// // remove it from the list now
// setConversations(conversations.filter((conversation) => conversation.id !== conversation_id));
// // if it the current selected one, update the selected conversation
// if (selectedConversation === conversation_id){
// setSelectedConversation(undefined)
// }
// }catch{
// }
// }
async function GetConversations(){
const {data, }: AxiosResponse<ConversationType[]> = await axiosInstance.get(`/conversations/`)
setConversations(data.map((item) => new Conversation({
id: item.id,
title: item.title
})))
}
useEffect(() => {
if(!loading && authenticated){
GetConversations();
}
}, [selectedConversation, authenticated, preferencesUpdated])
return(
<ConversationContext.Provider value={{conversations, setConversations, selectedConversation, setSelectedConversation, deleteConversation}}>
{children}
</ConversationContext.Provider>
)
}
export { ConversationContext, ConversationProvider }

View File

@@ -0,0 +1,138 @@
import { createContext, useContext } from "react";
import { ConverationAPI } from "types";
import { AuthContext } from "./AuthContext";
type MessageProviderProps ={
children? : ReactNode;
}
type IMessageContext = {
stateMessage: string;
setStateMessage: (message: string) => void;
conversationDetails: ConverationAPI [];
setConversationDetails: (conversationPrompts: ConverationAPI[]) => void;
isGeneratingMessage: boolean;
}
const initialValues = {
stateMessage: '',
setStateMessage: () => {},
conversationDetails: [],
setConversationDetails: () => {},
isGeneratingMessage: false
}
const MessageContext = createContext<IMessageContext>(initialValues);
const MessageProvider = ( {children}: MessageProviderProps) => {
const { authenticated, loading } = useContext(AuthContext);
const [subscribe, unsubscribe, socket, sendMessage]= useContext(WebSocketContext)
const { account } = useContext(AccountContext)
const {conversations, selectedConversation, setSelectedConversation} = useContext(ConversationContext);
const [stateMessage, setStateMessage] = useState<string>('')
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const messageRef = useRef('')
const messageResponsePart = useRef(0);
const conversationRef = useRef(conversationDetails)
const selectedConversationRef = useRef<undefined | number>(undefined)
async function GetConversationDetails(){
if(selectedConversation){
try{
//setPromptProcessing(true)
selectedConversationRef.current = selectedConversation;
const {data, }: AxiosResponse<ConversationPromptType[]> = await axiosInstance.get(`conversation_details?conversation_id=${selectedConversation}`)
const tempConversations: ConversationPrompt[] = data.map((item) => new ConversationPrompt({
message: item.message,
user_created: item.user_created,
created_timestamp: item.created_timestamp
}))
if(tempConversations.length === 1){
// we need to add another card because this is the first message
tempConversations.push(new ConversationPrompt({message: '', user_created:false}))
}
conversationRef.current = tempConversations
setConversationDetails(tempConversations)
}finally{
//setPromptProcessing(false)
}
}else{
setConversationDetails([])
}
}
useEffect(() => {
GetConversationDetails();
}, [selectedConversation])
useEffect(() => {
/* register a consistent channel name for identifing this chat messages */
const channelName = `ACCOUNT_ID_${account?.email}`
/* subscribe to channel and register callback */
subscribe(channelName, (message: string) => {
/* when a message is received just add it to the UI */
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
messageResponsePart.current = 0
conversationRef.current.pop()
//handleAssistantPrompt({prompt: messageRef.current})
setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
messageRef.current = ''
setStateMessage('')
setIsGeneratingMessage(false)
}
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
conversationRef.current = conversationDetails
setIsGeneratingMessage(true)
messageResponsePart.current = 2
}else if (message === 'CONVERSATION_ID'){
setIsGeneratingMessage(true)
messageResponsePart.current = 1
}else{
setIsGeneratingMessage(true)
if (messageResponsePart.current === 1){
// this has to do with the conversation id
if(!selectedConversation){
setSelectedConversation(Number(message))
}
}
else if (messageResponsePart.current === 2){
messageRef.current += message
setStateMessage(messageRef.current)
}
}
})
return () => {
/* unsubscribe from channel during cleanup */
unsubscribe(channelName)
}
}, [account, subscribe, unsubscribe, conversationDetails])
return(
<MessageContext.Provider value={{stateMessage, setStateMessage, conversationDetails, setConversationDetails, isGeneratingMessage}}>
{children}
</MessageContext.Provider>
)
}
export { MessageContext, MessageProvider}

View File

@@ -0,0 +1,208 @@
import React, { useEffect, createContext, useRef, useState, useContext, ReactNode } from 'react';
import { AccountContext } from './AccountContext';
import { AuthContext } from './AuthContext';
// ---
// Define Types and Interfaces
// ---
/**
* Interface for the Account Context value.
* Assumes account has at least an email property.
*/
interface IAccount {
email: string | null;
// Add other properties of the account object as needed
// e.g., userId: string; name: string;
}
export interface ChatMessage {
text: string;
sender: 'user' | 'ai';
}
/**
* Interface for the Auth Context value.
*/
interface IAuthContext {
authenticated: boolean;
loading: boolean;
}
/**
* Type for the message data received from the WebSocket.
* Adjust 'any' to a more specific type if the message structure is known.
*/
type WebSocketMessageData = string; // Assuming message.data is a string (e.g., JSON string)
/**
* Type for a channel callback function.
*/
type ChannelCallback = (data: WebSocketMessageData) => void;
/**
* Interface for the WebSocket Context value.
* This defines the shape of the array returned by `useContext(WebSocketContext)`.
*/
interface IWebSocketContext {
subscribe: (channel: string, callback: ChannelCallback) => void;
unsubscribe: (channel: string) => void;
socket: WebSocket | null;
sendMessages: (messages: ChatMessage[]) => void;
}
/**
* Props for the WebSocketProvider component.
*/
interface WebSocketProviderProps {
children: ReactNode;
}
// ---
// Create the WebSocket Context
// ---
// Provide a default value that matches the IWebSocketContext interface.
// This is used when a component tries to consume the context without a provider.
const WebSocketContext = createContext<IWebSocketContext>({
subscribe: () => {},
unsubscribe: () => {},
socket: null,
sendMessages: () => {},
});
// ---
// WebSocket Provider Component
// ---
function WebSocketProvider({ children }: WebSocketProviderProps) {
// Using useContext with explicit types for better type checking
const { authenticated, loading } = useContext<IAuthContext>(AuthContext);
const { account, setAccount } = useContext<{
account: IAccount | null;
setAccount: React.Dispatch<React.SetStateAction<IAccount | null>>;
}>(AccountContext);
// useRef for WebSocket instance
const ws = useRef<WebSocket | null>(null);
// useState for socket connection state
const [socket, setSocket] = useState<WebSocket | null>(null);
// useRef to store channel callbacks, mapping channel names to their callbacks
const channels = useRef<{ [key: string]: ChannelCallback }>({});
// useState for the current active channel (though not directly used in the current logic for message dispatch)
const [currentChannel, setCurrentChannel] = useState<string>('');
/**
* Subscribes a callback function to a specific WebSocket channel.
* @param channel The name of the channel to subscribe to.
* @param callback The function to be called when a message is received on this channel.
*/
const subscribe = (channel: string, callback: ChannelCallback) => {
setCurrentChannel(channel); // This seems to track the last subscribed channel globally, not per-message.
channels.current[channel] = callback;
};
/**
* Unsubscribes a callback from a specific WebSocket channel.
* @param channel The name of the channel to unsubscribe from.
*/
const unsubscribe = (channel: string) => {
delete channels.current[channel];
};
/**
* Sends a message over the WebSocket connection.
* Handles both text messages and file uploads (by converting file to base64).
* @param message The text message to send.
* @param account_iod The ID of the conversation this message belongs to.
*/
const sendMessages = (messages: ChatMessage[]) => {
if (socket && socket.readyState === WebSocket.OPEN) {
const data = {
messages: messages,
};
socket.send(JSON.stringify(data));
} else {
console.log('Error sending message. WebSocket is not open');
}
};
// ---
// WebSocket Initialization and Event Handling
// ---
useEffect(() => {
if (account?.email) {
// Ensure account and email exist before attempting to connect
// Close any existing connection before creating a new one
if (ws.current) {
ws.current.close();
}
ws.current = new WebSocket(
`ws://127.0.0.1:8010/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
);
ws.current.onopen = () => {
setSocket(ws.current);
console.log('WebSocket connected');
};
ws.current.onclose = () => {
setSocket(null);
console.log('WebSocket disconnected');
};
ws.current.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.current.onmessage = (event: MessageEvent) => {
const data: WebSocketMessageData = event.data;
// The original logic assumes a single active chat channel is always present
// and iterates Object.entries(channels.current)[0][0] to get its key.
// This might be problematic if multiple channels are intended to be active
// or if messages don't always correspond to the first subscribed channel.
// For now, retaining the original logic, but consider refining based on backend message structure.
const chatChannelKey = Object.keys(channels.current)[0];
if (chatChannelKey && channels.current[chatChannelKey]) {
// Call the callback associated with the identified channel
channels.current[chatChannelKey](data);
} else {
console.log('No active chat channel subscribed or message format unexpected.');
// Potentially handle generic messages or log the unhandled message
}
};
// Cleanup function: Closes the WebSocket connection when the component unmounts
// or when the 'account' dependency changes, triggering a re-run of this effect.
return () => {
if (ws.current) {
ws.current.close();
console.log('WebSocket cleaned up');
}
};
} else if (!loading && !authenticated) {
// If not authenticated and not loading, ensure WebSocket is closed if it was open.
if (ws.current) {
ws.current.close();
ws.current = null;
setSocket(null);
}
}
}, [account, authenticated, loading]); // Dependencies: reconnect if account or auth status changes
// ---
// Render the Provider
// ---
return (
<WebSocketContext.Provider value={{ subscribe, unsubscribe, socket, sendMessages }}>
{children}
</WebSocketContext.Provider>
);
}
export { WebSocketContext, WebSocketProvider };

View File

@@ -0,0 +1,42 @@
import { NavItem } from 'types';
const attorneyNavItems: NavItem[] = [
{
title: 'Home',
path: '/',
icon: 'ion:home-sharp',
active: true,
collapsible: false,
sublist: [
{
title: 'Dashboard',
path: '/',
active: false,
collapsible: false,
},
],
},
{
title: 'Profile',
path: '/profile',
icon: 'ph:user-circle',
active: true,
collapsible: false,
},
{
title: 'Conversations',
path: '/conversations',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Documents',
path: '/documents',
icon: 'ph:file',
active: true,
collapsible: false,
},
];
export default attorneyNavItems;

View File

@@ -0,0 +1,113 @@
import { NavItem } from 'types';
const basicNavItems: NavItem[] = [
{
title: 'Home',
path: '/',
icon: 'ion:home-sharp',
active: true,
collapsible: false,
sublist: [
{
title: 'Dashboard',
path: '/',
active: false,
collapsible: false,
},
],
},
{
title: 'Profile',
path: '/profile',
icon: 'ph:user-circle',
active: true,
collapsible: false,
},
{
title: 'Search',
path: '/property/search',
icon: 'ph:magnifying-glass',
active: true,
collapsible: false,
},
{
title: 'Education',
path: '/education',
icon: 'ph:student',
active: false,
collapsible: false,
},
{
title: 'Vendors',
path: '/vendors',
icon: 'ph:storefront',
active: false,
collapsible: false,
},
{
title: 'Messages',
path: '',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: true,
sublist: [
{
title: 'Offers',
path: 'offers',
icon: 'ph:certificate',
active: true,
collapsible: false,
},
{
title: 'Conversations',
path: 'conversations',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Bids',
path: 'bids',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
],
},
{
title: 'Tools',
path: '/tools',
icon: 'ph:toolbox',
active: true,
collapsible: true,
sublist: [
{
title: 'Mortgage Calculator',
path: '/mortgage-calculator',
active: true,
collapsible: false,
},
{
title: 'Amortization Table',
path: '/amoritization-table',
active: true,
collapsible: false,
},
{
title: 'Home Affordability',
path: '/home-affordability',
active: true,
collapsible: false,
},
{
title: 'Net Terms Sheet',
path: '/net-terms-sheet',
active: true,
collapsible: false,
},
],
},
];
export default basicNavItems;

View File

@@ -0,0 +1,406 @@
import { AutocompleteResponseAPI } from 'types';
export const test_autocomplete: AutocompleteResponseAPI = {
input: {
search: '1968 gree',
},
data: [
{
zip: '32073',
address: '1968 Green Apple Ct, Orange Park, FL, 32073',
city: 'Orange Park',
searchType: 'A',
stateId: '12',
latitude: 30.184959,
county: 'Clay County',
fips: '12019',
title: '1968 Green Apple Ct, Orange Park, FL, 32073',
house: '1968',
countyId: '019',
street: 'Green Apple Ct ',
location: 'POINT(-81.74479 30.184959)',
id: '43162123',
state: 'FL',
apn: '06-04-26-010687-005-00',
longitude: -81.74479,
},
{
zip: '46534',
address: '100 S & 100 E, Knox, IN, 46534',
city: 'Knox',
searchType: 'A',
stateId: '18',
latitude: 41.293923,
county: 'Starke County',
fips: '18149',
title: '100 S & 100 E, Knox, IN, 46534',
house: '100',
countyId: '149',
street: 'S & 100 E',
location: 'POINT(-86.683191 41.293923)',
id: '234714216',
state: 'IN',
apn: '75-06-19-400-008.000-003',
longitude: -86.683191,
},
{
zip: '46534',
address: '100 S & 100 E, Knox, IN, 46534',
city: 'Knox',
searchType: 'A',
stateId: '18',
latitude: 41.281191,
county: 'Starke County',
fips: '18149',
title: '100 S & 100 E, Knox, IN, 46534',
house: '100',
countyId: '149',
street: 'S & 100 E',
location: 'POINT(-86.686696 41.281191)',
id: '234714844',
state: 'IN',
apn: '75-06-30-200-002.000-003',
longitude: -86.686696,
},
{
zip: '47371',
address: '100 S & 100 E, Portland, IN, 47371',
city: 'Portland',
searchType: 'A',
stateId: '18',
latitude: 40.428412,
county: 'Jay County',
fips: '18075',
title: '100 S & 100 E, Portland, IN, 47371',
house: '100',
countyId: '075',
street: 'S & 100 E',
location: 'POINT(-84.960251 40.428412)',
id: '211811615',
state: 'IN',
apn: '38-07-21-400-007.003-033',
longitude: -84.960251,
},
{
zip: '47944',
address: '100 S & 100 E, Fowler, IN, 47944',
city: 'Fowler',
searchType: 'A',
stateId: '18',
county: 'Benton County',
fips: '18007',
title: '100 S & 100 E, Fowler, IN, 47944',
house: '100',
countyId: '007',
street: 'S & 100 E',
id: '247385118',
state: 'IN',
apn: '04-08-27-100-001.000-003',
},
{
zip: '47348',
address: '100 S & 100 W, Hartford City, IN, 47348',
city: 'Hartford City',
searchType: 'A',
stateId: '18',
county: 'Blackford County',
fips: '18009',
title: '100 S & 100 W, Hartford City, IN, 47348',
house: '100',
countyId: '009',
street: 'S & 100 W',
id: '212944381',
state: 'IN',
apn: '05-03-16-400-038.001-005',
},
{
zip: '47944',
address: '100 S & 100 W, Fowler, IN, 47944',
city: 'Fowler',
searchType: 'A',
stateId: '18',
latitude: 40.585368,
county: 'Benton County',
fips: '18007',
title: '100 S & 100 W, Fowler, IN, 47944',
house: '100',
countyId: '007',
street: 'S & 100 W',
location: 'POINT(-87.33213 40.585368)',
id: '247385119',
state: 'IN',
apn: '04-08-28-200-003.000-003',
longitude: -87.33213,
},
{
zip: '47944',
address: '100 S & 100 W, Fowler, IN, 47944',
city: 'Fowler',
searchType: 'A',
stateId: '18',
latitude: 40.59381,
county: 'Benton County',
fips: '18007',
title: '100 S & 100 W, Fowler, IN, 47944',
house: '100',
countyId: '007',
street: 'S & 100 W',
location: 'POINT(-87.34859 40.59381)',
id: '247385101',
state: 'IN',
apn: '04-08-20-400-011.000-003',
longitude: -87.34859,
},
{
zip: '47957',
address: '100 S & 1125w, Medaryville, IN, 47957',
city: 'Medaryville',
searchType: 'A',
stateId: '18',
county: 'Pulaski County',
fips: '18131',
title: '100 S & 1125w, Medaryville, IN, 47957',
house: '100',
countyId: '131',
street: 'S & 1125w ',
id: '209828103',
state: 'IN',
apn: '66-06-18-300-005.000-009',
},
{
zip: '84338',
address: '100 S 0100 W, Trenton, UT, 84338',
city: 'Trenton',
searchType: 'A',
stateId: '49',
latitude: 41.916231,
county: 'Cache County',
fips: '49005',
title: '100 S 0100 W, Trenton, UT, 84338',
house: '100',
countyId: '005',
street: 'S 0100 W',
location: 'POINT(-111.943645 41.916231)',
id: '308094883',
state: 'UT',
apn: '14-049-0029',
longitude: -111.943645,
},
{
zip: '84759',
address: '100 S 0300 W, Panguitch, UT, 84759',
city: 'Panguitch',
searchType: 'A',
stateId: '49',
latitude: 37.823867,
county: 'Garfield County',
fips: '49017',
title: '100 S 0300 W, Panguitch, UT, 84759',
house: '100',
countyId: '017',
street: 'S 0300 W',
location: 'POINT(-112.442373 37.823867)',
id: '308119618',
state: 'UT',
apn: '07-0063-0511',
longitude: -112.442373,
},
{
zip: '60563',
address: '270 W Diehl Rd, Naperville, IL, 60563',
city: 'Naperville',
searchType: 'A',
stateId: '17',
latitude: 41.800978,
county: 'Dupage County',
fips: '17043',
title: '270 W Diehl Rd, Naperville, IL, 60563',
house: '270',
countyId: '043',
street: 'W Diehl Rd ',
location: 'POINT(-88.15161 41.800978)',
id: '215989067',
state: 'IL',
apn: '07-01-409-006',
longitude: -88.15161,
},
{
zip: '29414',
address: '1968 Green Park Ave, Charleston, SC, 29414',
city: 'Charleston',
searchType: 'A',
stateId: '45',
latitude: 32.822323,
county: 'Charleston County',
fips: '45019',
title: '1968 Green Park Ave, Charleston, SC, 29414',
house: '1968',
countyId: '019',
street: 'Green Park Ave ',
location: 'POINT(-80.048738 32.822323)',
id: '52693725',
state: 'SC',
apn: '355-15-00-053',
longitude: -80.048738,
},
{
zip: '29566',
address: '1968 Green Pine Dr, Little River, SC, 29566',
city: 'Little River',
searchType: 'A',
stateId: '45',
latitude: 33.883239,
county: 'Horry County',
fips: '45051',
title: '1968 Green Pine Dr, Little River, SC, 29566',
house: '1968',
countyId: '051',
street: 'Green Pine Dr ',
location: 'POINT(-78.612189 33.883239)',
id: '203511208',
state: 'SC',
apn: '311-01-02-0008',
longitude: -78.612189,
},
{
zip: '44057',
address: '1968 Green Rd, Madison, OH, 44057',
city: 'Madison',
searchType: 'A',
stateId: '39',
latitude: 41.820413,
county: 'Lake County',
fips: '39085',
title: '1968 Green Rd, Madison, OH, 44057',
house: '1968',
countyId: '085',
street: 'Green Rd ',
location: 'POINT(-81.074411 41.820413)',
id: '9444522',
state: 'OH',
apn: '01-B-112-A-06-011-0',
longitude: -81.074411,
},
{
zip: '44121',
address: '1968 Green Rd, Cleveland, OH, 44121',
city: 'Cleveland',
searchType: 'A',
stateId: '39',
latitude: 41.554228,
county: 'Cuyahoga County',
fips: '39035',
title: '1968 Green Rd, Cleveland, OH, 44121',
house: '1968',
countyId: '035',
street: 'Green Rd ',
location: 'POINT(-81.546436 41.554228)',
id: '2733963',
state: 'OH',
apn: '117-35-005',
longitude: -81.546436,
},
{
zip: '24328',
address: '1968 Greenberry Rd, Fancy Gap, VA, 24328',
city: 'Fancy Gap',
searchType: 'A',
stateId: '51',
latitude: 36.694716,
county: 'Carroll County',
fips: '51035',
title: '1968 Greenberry Rd, Fancy Gap, VA, 24328',
house: '1968',
countyId: '035',
street: 'Greenberry Rd ',
location: 'POINT(-80.659349 36.694716)',
id: '308551940',
state: 'VA',
apn: '115-A-112',
longitude: -80.659349,
},
{
zip: '33837',
address: '1968 Greenbriar Ter, Davenport, FL, 33837',
city: 'Davenport',
searchType: 'A',
stateId: '12',
latitude: 28.21132,
county: 'Polk County',
fips: '12105',
title: '1968 Greenbriar Ter, Davenport, FL, 33837',
house: '1968',
countyId: '105',
street: 'Greenbriar Ter ',
location: 'POINT(-81.548718 28.21132)',
id: '324425597',
state: 'FL',
apn: '282619932940002040',
longitude: -81.548718,
},
{
zip: '32304',
address: '1968 Greencastle Ln, Tallahassee, FL, 32304',
city: 'Tallahassee',
searchType: 'A',
stateId: '12',
latitude: 30.461539,
county: 'Leon County',
fips: '12073',
title: '1968 Greencastle Ln, Tallahassee, FL, 32304',
house: '1968',
countyId: '073',
street: 'Greencastle Ln ',
location: 'POINT(-84.351199 30.461539)',
id: '155217710',
state: 'FL',
apn: '21-29-67-001-703-0',
longitude: -84.351199,
},
{
zip: '92019',
address: '1968 Greenfield Dr, El Cajon, CA, 92019',
city: 'El Cajon',
searchType: 'A',
stateId: '06',
latitude: 32.805653,
county: 'San Diego County',
fips: '06073',
title: '1968 Greenfield Dr, El Cajon, CA, 92019',
house: '1968',
countyId: '073',
street: 'Greenfield Dr ',
location: 'POINT(-116.908316 32.805653)',
id: '156389075',
state: 'CA',
apn: '508-031-14-00',
longitude: -116.908316,
},
{
zip: '63122',
address: '1968 Greenglen Dr, Apt 101, Saint Louis, MO, 63122',
city: 'Saint Louis',
searchType: 'A',
stateId: '29',
latitude: 38.573387,
county: 'St. Louis County',
fips: '29189',
title: '1968 Greenglen Dr, Apt 101, Saint Louis, MO, 63122',
house: '1968',
unit: '101',
countyId: '189',
street: 'Greenglen Dr ',
location: 'POINT(-90.441013 38.573387)',
id: '31780767',
state: 'MO',
apn: '24O-3-4-206-3',
longitude: -90.441013,
},
],
totalResults: 9,
returnedResults: 10,
statusCode: 200,
statusMessage: 'Success',
live: true,
requestExecutionTimeMS: '23ms',
};

View File

@@ -0,0 +1,682 @@
import { PropertyResponseAPI } from 'types';
export const test_property_search: PropertyResponseAPI = {
input: {
comps: true,
id: 9444522,
exact_match: true,
},
data: {
id: 175468968,
MFH2to4: false,
MFH5plus: false,
absenteeOwner: false,
adjustableRate: false,
assumable: false,
auction: false,
equity: 293000,
bankOwned: null,
cashBuyer: false,
cashSale: false,
corporateOwned: false,
death: true,
deathTransfer: false,
deedInLieu: false,
equityPercent: 43,
estimatedEquity: 321216,
estimatedMortgageBalance: '420784',
estimatedMortgagePayment: '2692',
estimatedValue: 742000,
floodZone: true,
floodZoneDescription: 'AREA OF MINIMAL FLOOD HAZARD',
floodZoneType: 'X',
freeClear: false,
highEquity: true,
inStateAbsenteeOwner: false,
inherited: false,
investorBuyer: false,
judgment: false,
lastSaleDate: '2020-06-30',
lastSalePrice: '475000',
lastUpdateDate: '2025-07-25 00:00:00 UTC',
lien: false,
loanTypeCodeFirst: 'COV',
loanTypeCodeSecond: null,
loanTypeCodeThird: null,
maturityDateFirst: '2051-01-01T00:00:00.000Z',
mlsActive: false,
mlsCancelled: false,
mlsDaysOnMarket: null,
mlsFailed: false,
mlsFailedDate: null,
mlsHasPhotos: false,
mlsLastSaleDate: null,
mlsLastStatusDate: null,
mlsListingDate: null,
mlsListingPrice: null,
mlsListingPricePerSquareFoot: null,
mlsPending: false,
mlsSold: false,
mlsSoldPrice: null,
mlsStatus: null,
mlsTotalUpdates: null,
mlsType: null,
mobileHome: false,
noticeType: null,
openMortgageBalance: 449000,
outOfStateAbsenteeOwner: false,
ownerOccupied: true,
preForeclosure: false,
privateLender: false,
propertyType: 'OTHER',
quitClaim: false,
reapi_loaded_at: null,
sheriffsDeed: false,
spousalDeath: false,
taxLien: false,
trusteeSale: false,
vacant: false,
warrantyDeed: false,
auctionInfo: {},
currentMortgages: [
{
amount: 449000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2021-000905',
deedType: '',
documentDate: '2020-12-23T00:00:00.000Z',
granteeName: 'Ryan Westfall, Teresa Marie Westfall',
interestRate: null,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Home Point Financial Corporation',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2051-01-01T00:00:00.000Z',
mortgageId: '1073281',
position: 'First',
recordingDate: '2021-01-05T00:00:00.000Z',
seqNo: 1,
term: '360',
termType: 'Month',
transactionType: null,
},
],
demographics: {
fmrEfficiency: '1440',
fmrFourBedroom: '2700',
fmrOneBedroom: '1560',
fmrThreeBedroom: '2270',
fmrTwoBedroom: '1790',
fmrYear: '2023',
hudAreaCode: 'METRO16980M16980',
hudAreaName: 'Chicago-Joliet-Naperville, IL HUD Metro FMR Area',
medianIncome: '110592',
suggestedRent: null,
},
foreclosureInfo: [],
lastSale: {
book: null,
page: null,
documentNumber: 'R2025-023071',
armsLength: false,
buyerNames: 'Ryan Westfall Trust',
documentType: 'Transfer On Death Deed',
documentTypeCode: 'DTDD',
downPayment: 0,
ltv: null,
ownerIndividual: true,
priorOwnerIndividual: true,
priorOwnerMonthsOwned: 58,
purchaseMethod: 'Cash Purchase',
recordingDate: '2025-04-23',
saleAmount: 0,
saleDate: '2025-04-22T00:00:00.000Z',
sellerNames: 'Ryan Westfall',
seqNo: 1,
transactionType: "Non-Arm's Length Transactions",
},
linkedProperties: {},
lotInfo: {
apn: '05-29-407-013',
apnUnformatted: '0529407013',
censusBlock: '3004',
censusBlockGroup: '4',
censusTract: '842601',
landUse: 'Residential',
legalDescription: 'BUTTERFIELD RIDGE UNIT NO 6 ALL',
legalSection: null,
lotAcres: '0.29',
lotNumber: null,
lotSquareFeet: 12632,
lotDepthFeet: 157.1,
lotWidthFeet: 121.4,
propertyClass: 'The general use for the property is for residential purposes',
propertyUse: null,
subdivision: null,
zoning: null,
},
mlsHistory: [],
mlsKeywords: {},
mortgageHistory: [
{
amount: 449000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2021-000905',
deedType: '',
documentDate: '2020-12-23T00:00:00.000Z',
granteeName: 'Ryan Westfall, Teresa Marie Westfall',
interestRate: null,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Home Point Financial Corporation',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2051-01-01T00:00:00.000Z',
mortgageId: '1073281',
open: true,
position: 'First',
recordingDate: '2021-01-05T00:00:00.000Z',
seqNo: 1,
term: '360',
termType: 'Month',
transactionType: null,
},
{
amount: 40000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2015-119963',
deedType: null,
documentDate: '2015-10-23T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 3.25,
interestRateType: 'Variable',
lenderCode: 'B',
lenderName: 'Inland Bank & Trust',
lenderType: 'Bank',
loanType: 'Credit Line (Revolving)',
loanTypeCode: 'LOC',
maturityDate: null,
mortgageId: '942198',
open: false,
position: null,
recordingDate: '2015-10-30T00:00:00.000Z',
seqNo: 2,
term: null,
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2012-152788',
deedType: null,
documentDate: '2012-10-19T00:00:00.000Z',
granteeName: 'Richaro Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Fairway Independent Mortgage Corp',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2042-11-01T00:00:00.000Z',
mortgageId: '854771',
open: false,
position: null,
recordingDate: '2012-10-30T00:00:00.000Z',
seqNo: 3,
term: '360',
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2011-143855',
deedType: null,
documentDate: '2011-11-14T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Fairway Independent Mortgage Corp',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2041-12-01T00:00:00.000Z',
mortgageId: '816787',
open: false,
position: null,
recordingDate: '2011-11-28T00:00:00.000Z',
seqNo: 4,
term: '360',
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2010-172395',
deedType: null,
documentDate: '2010-11-23T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Fairway Independent Mortgage Corp',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2040-12-01T00:00:00.000Z',
mortgageId: '784635',
open: false,
position: null,
recordingDate: '2010-12-10T00:00:00.000Z',
seqNo: 5,
term: '360',
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2010-117571',
deedType: null,
documentDate: '2010-07-19T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Fairway Independent Mortgage Corp',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2040-08-01T00:00:00.000Z',
mortgageId: '771632',
open: false,
position: null,
recordingDate: '2010-09-08T00:00:00.000Z',
seqNo: 6,
term: '350',
termType: 'Month',
transactionType: null,
},
{
amount: 25000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2009-140215',
deedType: null,
documentDate: '2009-07-01T00:00:00.000Z',
granteeName: 'Richard L Maciejewski, Jean M Maciejewski',
interestRate: 3.25,
interestRateType: 'Variable',
lenderCode: 'B',
lenderName: 'First Choice Bank',
lenderType: 'Bank',
loanType: 'Credit Line (Revolving)',
loanTypeCode: 'LOC',
maturityDate: null,
mortgageId: '727300',
open: false,
position: null,
recordingDate: '2009-09-10T00:00:00.000Z',
seqNo: 7,
term: null,
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2009-140214',
deedType: null,
documentDate: '2009-06-30T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'M',
lenderName: 'Metlife Home Loans',
lenderType: 'Mortgage Company',
loanType: 'New Conventional',
loanTypeCode: 'COV',
maturityDate: '2039-08-01T00:00:00.000Z',
mortgageId: '727299',
open: false,
position: null,
recordingDate: '2009-09-10T00:00:00.000Z',
seqNo: 8,
term: '350',
termType: 'Month',
transactionType: null,
},
{
amount: 325000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2009-039645',
deedType: null,
documentDate: '2008-12-03T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 0,
interestRateType: null,
lenderCode: 'B',
lenderName: 'Jpmorgan Chase Bank Na',
lenderType: 'Bank',
loanType: 'Unknown',
loanTypeCode: 'U',
maturityDate: '2039-01-01T00:00:00.000Z',
mortgageId: '705262',
open: false,
position: null,
recordingDate: '2009-03-19T00:00:00.000Z',
seqNo: 9,
term: '350',
termType: 'Month',
transactionType: null,
},
{
amount: 107000,
assumable: false,
book: null,
page: null,
documentNumber: 'R2007-162440',
deedType: null,
documentDate: '2007-08-11T00:00:00.000Z',
granteeName: 'Richard Maciejewski, Jean Maciejewski',
interestRate: 8.25,
interestRateType: 'Variable',
lenderCode: 'B',
lenderName: 'Jpmorgan Chase Bank Na',
lenderType: 'Bank',
loanType: 'Credit Line (Revolving)',
loanTypeCode: 'LOC',
maturityDate: '2037-08-11T00:00:00.000Z',
mortgageId: '645363',
open: false,
position: null,
recordingDate: '2007-08-31T00:00:00.000Z',
seqNo: 10,
term: '360',
termType: 'Month',
transactionType: null,
},
],
neighborhood: {
center: 'POINT(-88.118480587841 41.8328129099628)',
id: '323789',
name: 'Stonehedge',
type: 'subdivision',
},
ownerInfo: {
absenteeOwner: false,
companyName: null,
corporateOwned: false,
equity: 293000,
inStateAbsenteeOwner: false,
mailAddress: {
address: '1968 Greensboro Dr',
addressFormat: 'S',
carrierRoute: 'C049',
city: null,
county: null,
fips: '17043',
house: null,
label: '',
preDirection: null,
state: null,
street: 'Greensboro',
streetType: null,
unit: null,
unitType: null,
zip: null,
zip4: null,
},
outOfStateAbsenteeOwner: false,
owner1FirstName: 'Ryan',
owner1FullName: 'Ryan Westfall',
owner1LastName: 'Westfall',
owner1Type: 'Individual',
owner2FirstName: null,
owner2FullName: '',
owner2LastName: null,
owner2Type: 'Other',
ownerOccupied: true,
ownershipLength: 3,
},
propertyInfo: {
address: {
address: '1968 Greensboro Dr',
carrierRoute: 'C049',
city: 'Wheaton',
congressionalDistrict: '03',
county: 'Dupage County',
fips: '17043',
house: '1968',
jurisdiction: 'Dupage County',
label: '1968 Greensboro Dr, Wheaton, IL 60189',
preDirection: null,
state: 'IL',
street: 'Greensboro',
streetType: 'Dr',
unit: null,
unitType: null,
zip: '60189',
zip4: '8132',
},
airConditioningType: 'Central',
attic: false,
basementFinishedPercent: 0,
basementSquareFeet: 1127,
basementSquareFeetFinished: 0,
basementSquareFeetUnfinished: 0,
basementType: null,
bathrooms: 3,
bedrooms: null,
breezeway: false,
buildingSquareFeet: 2598,
buildingsCount: 0,
carport: false,
construction: 'Mixed',
deck: false,
deckArea: 0,
featureBalcony: false,
fireplace: false,
fireplaces: null,
garageSquareFeet: 528,
garageType: 'Garage',
heatingFuelType: null,
heatingType: 'Central',
hoa: false,
interiorStructure: null,
latitude: 41.833230929728565,
livingSquareFeet: 2598,
longitude: -88.12083257242568,
lotSquareFeet: 12632,
parcelAccountNumber: null,
parkingSpaces: 2,
partialBathrooms: 0,
patio: false,
patioArea: '0',
plumbingFixturesCount: 0,
pool: false,
poolArea: 0,
porchArea: null,
porchType: null,
pricePerSquareFoot: 183,
propertyUse: 'Single Family Residence',
propertyUseCode: 385,
roofConstruction: null,
roofMaterial: '109',
roomsCount: 0,
rvParking: false,
safetyFireSprinklers: false,
stories: null,
taxExemptionHomeownerFlag: true,
unitsCount: 0,
utilitiesSewageUsage: null,
utilitiesWaterSource: null,
yearBuilt: 1984,
},
saleHistory: [
{
book: null,
page: null,
documentNumber: 'R2025-023071',
armsLength: false,
buyerNames: 'Ryan Westfall Trust',
documentType: 'Transfer On Death Deed',
documentTypeCode: 'DTDD',
downPayment: 0,
ltv: null,
ownerIndividual: true,
purchaseMethod: 'Cash Purchase',
recordingDate: '2025-04-23T00:00:00.000Z',
saleAmount: 0,
saleDate: '2025-04-22T00:00:00.000Z',
sellerNames: 'Ryan Westfall',
seqNo: 1,
transactionType: "Non-Arm's Length Transactions",
},
{
book: null,
page: null,
documentNumber: 'R2020-068127',
armsLength: true,
buyerNames: 'Ryan Westfall, Teresa Marie Westfall',
documentType: 'Warranty Deed',
documentTypeCode: 'DTWD',
downPayment: 23750,
ltv: 95,
ownerIndividual: true,
purchaseMethod: 'Financed',
recordingDate: '2020-06-30T00:00:00.000Z',
saleAmount: 475000,
saleDate: '2020-06-26T00:00:00.000Z',
sellerNames: 'Jean Maciejewski',
seqNo: 2,
transactionType: 'Arms Length Residential Transactions (Purchase/Resales)',
},
],
schools: [
{
city: 'Wheaton',
enrollment: 1982,
grades: '9-12',
levels: {
elementary: null,
high: true,
middle: null,
preschool: null,
},
location: 'POINT(-88.146118 41.834869)',
name: 'Wheaton Warrenville South High School',
parentRating: 3,
rating: 8,
state: 'IL',
street: '1920 Wiesbrook Road South',
type: 'Public',
zip: '60189',
},
{
city: 'Wheaton',
enrollment: 620,
grades: '6-8',
levels: {
elementary: null,
high: null,
middle: true,
preschool: null,
},
location: 'POINT(-88.109077 41.852871)',
name: 'Edison Middle School',
parentRating: 4,
rating: 4,
state: 'IL',
street: '1125 South Wheaton Avenue',
type: 'Public',
zip: '60189',
},
{
city: 'Wheaton',
enrollment: 507,
grades: 'PK-5',
levels: {
elementary: true,
high: null,
middle: null,
preschool: true,
},
location: 'POINT(-88.129822 41.851345)',
name: 'Madison Elementary School',
parentRating: 4,
rating: 4,
state: 'IL',
street: '1620 Mayo Avenue',
type: 'Public',
zip: '60189',
},
{
city: 'Wheaton',
enrollment: 459,
grades: 'K-5',
levels: {
elementary: true,
high: null,
middle: null,
preschool: null,
},
location: 'POINT(-88.108467 41.857048)',
name: 'Whittier Elementary School',
parentRating: 5,
rating: 4,
state: 'IL',
street: '218 West Park Avenue',
type: 'Public',
zip: '60189',
},
],
taxInfo: {
assessedImprovementValue: 150477,
assessedLandValue: 51778,
assessedValue: 202255,
assessmentYear: 2024,
estimatedValue: null,
marketImprovementValue: 451431,
marketLandValue: 155334,
marketValue: 606765,
propertyId: 175468968,
taxAmount: '12520.12',
taxDelinquentYear: null,
year: 2024,
},
comps: [],
},
statusCode: 200,
statusMessage: 'Success',
live: true,
requestExecutionTimeMS: '29ms',
propertyLookupExecutionTimeMS: '23ms',
compsLookupExecutionTimeMS: null,
};

View File

@@ -1,11 +1,4 @@
export interface NavItem {
title: string;
path: string;
icon?: string;
active: boolean;
collapsible: boolean;
sublist?: NavItem[];
}
import { NavItem } from 'types';
const navItems: NavItem[] = [
{
@@ -21,73 +14,19 @@ const navItems: NavItem[] = [
active: false,
collapsible: false,
},
{
title: 'Sales',
path: '/',
active: false,
collapsible: false,
},
],
},
{
title: 'Authentication',
path: 'authentication',
icon: 'f7:exclamationmark-shield-fill',
active: true,
collapsible: true,
sublist: [
{
title: 'Sign In',
path: 'login',
title: 'Profile',
path: '/profile',
icon: 'ph:user-circle',
active: true,
collapsible: false,
},
{
title: 'Sign Up',
path: 'sign-up',
active: true,
collapsible: false,
},
{
title: 'Forgot password',
path: 'forgot-password',
active: true,
collapsible: false,
},
{
title: 'Reset password',
path: 'reset-password',
active: true,
collapsible: false,
},
],
},
{
title: 'Notification',
path: '#!',
icon: 'zondicons:notifications',
active: false,
collapsible: false,
},
{
title: 'Calendar',
path: '#!',
icon: 'ph:calendar',
active: false,
collapsible: false,
},
{
title: 'Message',
path: '#!',
icon: 'ph:chat-circle-dots-fill',
active: false,
collapsible: false,
},
{
title: 'Property',
path: '/property',
icon: 'ph:house-line',
title: 'Search',
path: '/property/search',
icon: 'ph:magnifying-glass',
active: true,
collapsible: false,
},
@@ -101,11 +40,74 @@ const navItems: NavItem[] = [
{
title: 'Vendors',
path: '/vendors',
icon: 'ph:toolbox',
icon: 'ph:storefront',
active: true,
collapsible: false,
},
{
title: 'Messages',
path: '',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: true,
sublist: [
{
title: 'Offers',
path: 'offers',
icon: 'ph:certificate',
active: true,
collapsible: false,
},
{
title: 'Conversations',
path: 'conversations',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Bids',
path: 'bids',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
],
},
{
title: 'Tools',
path: '/tools',
icon: 'ph:toolbox',
active: true,
collapsible: true,
sublist: [
{
title: 'Mortgage Calculator',
path: 'mortgage-calculator',
active: true,
collapsible: false,
},
{
title: 'Amortization Table',
path: 'amoritization-table',
active: true,
collapsible: false,
},
{
title: 'Home Affordability',
path: 'home-affordability',
active: true,
collapsible: false,
},
{
title: 'Net Terms Sheet',
path: 'net-terms-sheet',
active: true,
collapsible: false,
},
],
},
];
export default navItems;

View File

@@ -0,0 +1,42 @@
import { NavItem } from 'types';
const vendorNavItems: NavItem[] = [
{
title: 'Home',
path: '/',
icon: 'ion:home-sharp',
active: true,
collapsible: false,
sublist: [
{
title: 'Dashboard',
path: '/',
active: false,
collapsible: false,
},
],
},
{
title: 'Profile',
path: '/profile',
icon: 'ph:user-circle',
active: true,
collapsible: false,
},
{
title: 'Conversations',
path: '/conversations',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Bids',
path: '/vendor-bids',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
];
export default vendorNavItems;

View File

@@ -4,7 +4,7 @@ const Footer = () => {
return (
<Stack
direction="row"
justifyContent={{ xs: 'center', md: 'flex-end' }}
justifyContent={{ xs: 'center' }}
ml={{ xs: 3.75, lg: 34.75 }}
mr={3.75}
my={3.75}
@@ -14,7 +14,7 @@ const Footer = () => {
href="https://ditchtheagent.com/"
target="_blank"
rel="noopener"
sx={{ color: 'text.primary', '&:hover': { color: 'primary.main' } }}
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
>
Ditch The Agent
</Link>

View File

@@ -1,4 +1,4 @@
import { ReactElement } from 'react';
import { ReactElement, useContext } from 'react';
import {
Link,
List,
@@ -12,10 +12,52 @@ import {
import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image';
import navItems from 'data/nav-items';
import NavButton from './NavButton';
import { axiosInstance } from '../../../axiosApi.js';
import { useNavigate } from 'react-router-dom';
import { AuthContext } from 'contexts/AuthContext';
import { AccountContext } from 'contexts/AccountContext.js';
import navItems from 'data/nav-items';
import vendorNavItems from 'data/vendor-nav-items.js';
import basicNavItems from 'data/basic-nav-items.js';
import { NavItem } from 'types.js';
import attorneyNavItems from 'data/attorney-nav-items.js';
const Sidebar = (): ReactElement => {
const navigate = useNavigate();
const { authenticated, setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
const { account, accountLoading } = useContext(AccountContext);
let nav_items: NavItem[] = [];
if (account && !accountLoading) {
if (account.user_type === 'property_owner') {
if (account.tier === 'premium') {
nav_items = navItems;
} else {
nav_items = basicNavItems;
}
} else if (account.user_type === 'vendor') {
nav_items = vendorNavItems;
} else if (account.user_type === 'attorney') {
nav_items = attorneyNavItems;
}
}
const handleSignOut = async () => {
try {
const response = await axiosInstance.post('/logout/', {
refresh_token: localStorage.getItem('refresh_token'),
});
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
axiosInstance.defaults.headers['Authorization'] = null;
setAuthentication(false);
} finally {
navigate('/authentication/login/');
}
};
return (
<Stack
justifyContent="space-between"
@@ -66,7 +108,7 @@ const Sidebar = (): ReactElement => {
width: 178,
}}
>
{navItems.map((navItem, index) => (
{nav_items.map((navItem, index) => (
<NavButton key={index} navItem={navItem} Link={Link} />
))}
</List>
@@ -85,8 +127,8 @@ const Sidebar = (): ReactElement => {
LinkComponent={Link}
href="/"
sx={{
backgroundColor: 'background.paper',
color: 'primary.main',
backgroundColor: 'background.default',
color: 'common.white',
'&:hover': {
backgroundColor: 'primary.main',
color: 'common.white',
@@ -97,7 +139,7 @@ const Sidebar = (): ReactElement => {
<ListItemIcon>
<IconifyIcon icon="ri:logout-circle-line" />
</ListItemIcon>
<ListItemText>Log out</ListItemText>
<ListItemText onClick={handleSignOut}>Log out</ListItemText>
</ListItemButton>
</ListItem>
</List>

View File

@@ -86,28 +86,6 @@ const Topbar = ({ handleDrawerToggle }: TopbarProps): ReactElement => {
<Typography variant="h5" component="h5">
{pathname === '/' ? 'Dashboard' : title}
</Typography>
<TextField
variant="outlined"
placeholder="Search..."
InputProps={{
endAdornment: (
<InputAdornment position="end" sx={{ width: 24, height: 24 }}>
<IconifyIcon icon="mdi:search" width={1} height={1} />
</InputAdornment>
),
}}
fullWidth
sx={{ maxWidth: 330 }}
/>
</Stack>
<Stack direction="row" alignItems="center" gap={{ xs: 1, sm: 1.75 }}>
<LanguageDropdown />
<IconButton color="inherit" centerRipple sx={{ bgcolor: 'inherit', p: 0.75 }}>
<Badge badgeContent={1} color="primary">
<IconifyIcon icon="carbon:notification-filled" width={24} height={24} />
</Badge>
</IconButton>
<AccountDropdown />
</Stack>
</Toolbar>
</AppBar>

View File

@@ -1,8 +1,6 @@
import { PropsWithChildren, ReactElement, useState } from 'react';
import { Box, Drawer, Stack, Toolbar } from '@mui/material';
import Sidebar from 'layouts/main-layout/Sidebar/Sidebar';
import Topbar from 'layouts/main-layout/Topbar/Topbar';
import Footer from './Footer';
@@ -78,9 +76,9 @@ const MainLayout = ({ children }: PropsWithChildren): ReactElement => {
pt: 12,
width: 1,
pb: 0,
alignItems: 'start',
}}
>
{children}
<FloatingChatButton />
</Toolbar>

View File

@@ -7,23 +7,22 @@ import { CssBaseline, ThemeProvider } from '@mui/material';
import BreakpointsProvider from 'providers/BreakpointsProvider.tsx';
import router from 'routes/router.tsx';
import { AuthProvider } from 'contexts/AuthContext.tsx';
import { AccountProvider } from 'contexts/AccountContext.tsx';
import { WebSocketProvider } from 'contexts/WebSocketContext.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<BreakpointsProvider>
<AuthProvider>
<AccountProvider>
<WebSocketProvider>
<CssBaseline />
<RouterProvider router={router} />
</WebSocketProvider>
</AccountProvider>
</AuthProvider>
</BreakpointsProvider>
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,75 @@
import React, { useState, useEffect } from 'react';
import {
Container,
Typography,
Grid,
Card,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
} from '@mui/material';
import { BidAPI, PropertiesAPI } from 'types';
import { AddBidDialog } from 'components/sections/dashboard/Home/Bids/AddBidDialog';
import { BidCard } from 'components/sections/dashboard/Home/Bids/BidCard';
import { axiosInstance } from '../../axiosApi';
import { AxiosResponse } from 'axios';
const BidsPage: React.FC = () => {
const [bids, setBids] = useState<BidAPI[]>([]);
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
const [openAddBidDialog, setOpenAddBidDialog] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
// You'll need an endpoint to get all properties for the current owner
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
setProperties(data);
// You'll need an endpoint to get all bids for the current owner's properties
const { data: bidData }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
setBids(bidData);
};
const handleDeleteBid = async (bidId: number) => {
try {
await axios.delete(`/api/bids/${bidId}/`);
fetchData(); // Refresh the list
} catch (error) {
console.error('Failed to delete bid', error);
}
};
return (
<Container>
<Typography variant="h4" gutterBottom sx={{ color: 'background.paper' }}>
My Property Bids
</Typography>
<Button variant="contained" onClick={() => setOpenAddBidDialog(true)}>
Add New Bid
</Button>
<Grid container spacing={3} sx={{ mt: 3 }}>
{bids.map((bid) => (
<Grid item xs={12} key={bid.id}>
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
</Grid>
))}
</Grid>
<AddBidDialog
open={openAddBidDialog}
onClose={() => setOpenAddBidDialog(false)}
properties={properties}
onBidAdded={fetchData}
/>
</Container>
);
};
export default BidsPage;

View File

@@ -1,29 +1,161 @@
import { ReactElement } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import Grid from '@mui/material/Unstable_Grid2';
import { EducationInfoCards } from 'components/sections/dashboard/Home/Education/EducationInfo';
import { ReactElement, useEffect, useState } from 'react';
import {axiosInstance} from '../../axiosApi'
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
import { GenericCategory, GenericItem, VideoAPI, VideoCategory, VideoItem, VideoProgressAPI } from 'types';
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
const Education = (): ReactElement => {
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
// Simulate fetching data from backend
let fetchedVideos: VideoItem[] = []
useEffect(() => {
// In a real app, you'd make an API call here
const fetchVideos = async () => {
// Replace with your actual API call
try{
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/')
if(data.length > 0){
fetchedVideos = data.map(item => {
console.log(item)
return {
id: String(item.video.id),
progress_id: item.id,
name: item.video.title,
description: item.video.description,
category: item.video.category.name,
categoryId: item.video.category.id,
status: item.status,
progress: item.progress,
videoUrl: item.video.link,
duration: item.video.duration,
}
})
setAllVideos(fetchedVideos)
}
}catch (error){
console.log('there was an error', error)
}
const categoryMap = new Map<string, {name: string; total: number; completed: number; description: string; imageUrl: string; }>();
// Populate category details (you might hardcode descriptions/images or fetch them)
// For demonstration, let's assume a default image and generate a description
const defaultCategoryImages: { [key: string]: string } = {
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
// Add more as needed
};
fetchedVideos.forEach(video => {
const categoryId = video.categoryId;
if (!categoryMap.has(categoryId)) {
let categoryName = video.category;
categoryMap.set(categoryId, {
name: categoryName,
total: 0,
completed: 0,
description: `Explore ${video.category} concepts and build your skills.`,
imageUrl: defaultCategoryImages[video.category] || 'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
});
}
const categoryData = categoryMap.get(categoryId)!;
categoryData.total += 1;
if (video.status === 'completed') {
categoryData.completed += 1;
}
});
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(([id, data]) => ({
id, // id is the category name here
name: data.name,
description: data.description,
imageUrl: data.imageUrl,
totalVideos: data.total,
completedVideos: data.completed,
categoryProgress: (data.total > 0) ? (data.completed / data.total) * 100 : 0,
}));
setVideoCategories(processedCategories);
};
fetchVideos();
}, []);
// const handleSelectCategory = (categoryName: string) => {
// setSelectedCategory(categoryName);
// };
// const handleBackToCategories = () => {
// setSelectedCategory(null);
// };
return(
<Grid
container
component="main"
columns={12}
spacing={3.75}
flexGrow={1}
pt={4.375}
pr={1.875}
pb={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
pl: { xs: 3.75, lg: 0 },
}}
>
<Grid xs={12} md={12}>
<EducationInfoCards />
</Grid>
</Grid>
<DashboardTemplate<VideoCategory, VideoItem>
pageTitle="Educational Videos"
data={{ categories: videoCategories, items: allVideos }}
renderCategoryGrid={(categories, onSelectCategory) => (
<CategoryGridTemplate
categories={categories}
onSelectCategory={(id) => onSelectCategory(id)}
renderCategoryCard={(category, onSelect) => (
<VideoCategoryCard category={category as VideoCategory} onSelectCategory={onSelect} />
)}
/>
)}
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
<ItemListDetailTemplate
category={selectedCategory}
items={itemsInSelectedCategory}
onBack={onBack}
renderListItem={(item, isSelected, onSelect) => (
<VideoListItem video={item as VideoItem} isSelected={isSelected}
onSelect={() => {
console.log('selecting')
onSelect(item.id)}
} />
)}
renderItemDetail={(item) => (
<VideoPlayer video={item as VideoItem} />
)}
/>
)}
/>
)
// (return(
// <Container maxWidth="lg" sx={{ mt: 4 }}>
// <Typography variant="h4" component="h1" gutterBottom>
// Educational Videos
// </Typography>
// {selectedCategory ? (
// <VideoPlayerPage
// categoryName={selectedCategory}
// videos={videos.filter(video => video.category === selectedCategory)}
// onBack={handleBackToCategories}
// // You'll need to pass functions for updating video progress back to Dashboard
// // For simplicity, we'll assume updates happen on the backend or in a global state
// />
// ) : (
// <CategoryGrid categories={categories} onSelectCategory={handleSelectCategory} />
// )}
// </Container>
// )
}
export default Education;

View File

@@ -0,0 +1,362 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import {
ConverationAPI,
ConversationItem,
GenericCategory,
MessagesAPI,
VendorCategory,
VendorItem,
} from 'types';
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import { axiosInstance } from '../../axiosApi';
import {
Box,
Container,
List,
Grid,
ListItem,
ListItemText,
Typography,
Paper,
TextField,
Button,
Avatar,
Stack,
} from '@mui/material';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
import SendIcon from '@mui/icons-material/Send';
import { AxiosResponse } from 'axios';
import { AccountContext } from 'contexts/AccountContext';
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
import { formatTimestamp } from 'utils';
interface Message {
id: number;
senderId: 'owner' | 'vendor'; // 'owner' represents the property owner, 'vendor' is the other party
content: string;
timestamp: string; // ISO string for date/time
}
interface Conversation {
id: number;
withName: string; // Name of the vendor or property owner
lastMessageSnippet: string;
lastMessageTimestamp: string;
messages: Message[];
}
const Messages = (): ReactElement => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
const [newMessageContent, setNewMessageContent] = useState<string>('');
const { account } = useContext(AccountContext);
// Auto-scroll to the bottom of the messages when they update
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchConversations = async () => {
try {
const { data }: AxiosResponse<ConverationAPI[]> =
await axiosInstance.get('/conversations/');
console.log(data);
if (data.length > 0) {
console.log(data);
const fetchedConversations: Conversation[] = data.map((item) => {
const lastMessageSnippet: string =
item.messages.length > 0 ? item.messages[item.messages.length - 1].text : '';
const messages: Message[] = item.messages.map((message) => {
return {
id: message.id,
content: message.text,
timestamp: message.timestamp,
senderId: message.sender === item.property_owner.user.id ? 'owner' : 'vendor',
};
});
console.log(messages);
return {
id: item.id,
withName: item.vendor.business_name,
lastMessageTimestamp: item.updated_at,
lastMessageSnippet: lastMessageSnippet,
messages: messages,
};
});
console.log(fetchedConversations);
setConversations(fetchedConversations);
}
} catch (error) {}
};
fetchConversations();
}, []);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
// Handle sending a new message
const handleSendMessage = async () => {
if (!newMessageContent.trim() || !selectedConversationId) {
return; // Don't send empty messages or if no conversation is selected
}
// send the message to the backend
try {
const { data }: AxiosResponse<MessagesAPI> = await axiosInstance.post(
`/conversations/${selectedConversationId}/messages/`,
{
sender: account?.id,
text: newMessageContent.trim(),
conversation: selectedConversationId,
},
);
console.log(data);
const newMessage: Message = {
id: data.id,
senderId: 'owner', // Assuming the current user is the 'owner'
content: newMessageContent.trim(),
timestamp: data.timestamp,
};
setConversations((prevConversations) =>
prevConversations.map((conv) =>
conv.id === selectedConversationId
? {
...conv,
messages: [...conv.messages, newMessage],
lastMessageSnippet: newMessage.content,
lastMessageTimestamp: newMessage.timestamp,
}
: conv,
),
);
setNewMessageContent(''); // Clear the input field
} catch (error) {}
};
return (
<Container
maxWidth="lg"
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
>
<Paper
elevation={3}
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
>
<Grid container sx={{ height: '100%' }}>
{/* Left Panel: Conversation List */}
<Grid
item
xs={12}
md={4}
sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
<Stack direction="row" sx={{ width: '100%' }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
Conversations
</Typography>
{account?.user_type === 'property_owner' && (
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
New Conversation
</Button>
)}
</Stack>
</Box>
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
{conversations.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
<ChatBubbleOutlineIcon sx={{ fontSize: 40, mb: 1 }} />
<Typography>No conversations yet.</Typography>
</Box>
) : (
conversations
.sort(
(a, b) =>
new Date(b.lastMessageTimestamp).getTime() -
new Date(a.lastMessageTimestamp).getTime(),
)
.map((conv) => (
<ListItem
key={conv.id}
button
selected={selectedConversationId === conv.id}
onClick={() => setSelectedConversationId(conv.id)}
sx={{ py: 1.5, px: 2 }}
>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{conv.withName}
</Typography>
}
secondary={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flexGrow: 1, pr: 1 }}
>
{conv.lastMessageSnippet}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatTimestamp(conv.lastMessageTimestamp)}
</Typography>
</Box>
}
/>
</ListItem>
))
)}
</List>
</Grid>
{/* Right Panel: Conversation Detail */}
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
{selectedConversation ? (
<>
{/* Conversation Header */}
<Box
sx={{
p: 3,
borderBottom: '1px solid',
borderColor: 'grey.200',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<Avatar sx={{ bgcolor: 'purple.200' }}>
{selectedConversation.withName
.split(' ')
.map((n) => n[0])
.join('')}
</Avatar>
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{selectedConversation.withName}
</Typography>
</Box>
{/* Messages Area */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
{selectedConversation.messages.map((message) => (
<Box
key={message.id}
sx={{
display: 'flex',
justifyContent: message.senderId === 'owner' ? 'flex-end' : 'flex-start',
mb: 2,
}}
>
<Box
sx={{
maxWidth: '75%',
p: 1.5,
borderRadius: 2,
bgcolor: message.senderId === 'owner' ? 'purple.200' : 'lightblue.50',
color: 'grey.800',
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
}}
>
<Typography variant="body2" sx={{ fontWeight: 'medium', mb: 0.5 }}>
{message.senderId === 'owner' ? 'You' : selectedConversation.withName}
</Typography>
<Typography variant="body1">{message.content}</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', textAlign: 'right', mt: 0.5 }}
>
{formatTimestamp(message.timestamp)}
</Typography>
</Box>
</Box>
))}
<div ref={messagesEndRef} /> {/* Scroll target */}
</Box>
{/* Message Input */}
<Box
sx={{
p: 2,
borderTop: '1px solid',
borderColor: 'grey.200',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<TextField
fullWidth
variant="outlined"
placeholder="Type your message..."
value={newMessageContent}
onChange={(e) => setNewMessageContent(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSendMessage();
}
}}
size="small"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
<Button
variant="contained"
color="primary"
onClick={handleSendMessage}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Send
</Button>
</Box>
</>
) : (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
p: 3,
color: 'grey.500',
}}
>
<ChatBubbleOutlineIcon sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6">Select a conversation to view messages</Typography>
<Typography variant="body2">
Click on a conversation from the left panel to get started.
</Typography>
</Box>
)}
</Grid>
</Grid>
</Paper>
</Container>
);
};
export default Messages;

View File

@@ -0,0 +1,340 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import { ConverationAPI, ConversationItem, GenericCategory, MessagesAPI, OfferAPI, VendorCategory, VendorItem } from 'types';
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import {axiosInstance} from '../../axiosApi'
import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack, Accordion, AccordionActions, AccordionSummary, AccordionDetails, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
import SendIcon from '@mui/icons-material/Send';
import { AxiosResponse } from 'axios';
import { AccountContext } from 'contexts/AccountContext';
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
import { formatTimestamp } from 'utils';
import CreateOfferDialog from 'components/sections/dashboard/Home/Offer/CreateOfferDialog';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
interface OfferDetail {
id: number;
senderId: 'owner' | 'vendor'; // 'owner' represents the property owner, 'vendor' is the other party
content: string;
timestamp: string; // ISO string for date/time
}
interface Offer {
id: number;
sender: string; // the name of the person who sent it
sender_id: number;
property_id: number;
address: string;
status: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'countered';
is_active: boolean;
lastMessageTimestamp: string;
market_value: string;
offer_value: string;
}
type submitOfferProps = {
offer_id: number,
sender_id: number,
property_id: number,
}
const Offers = (): ReactElement => {
const [offers, setOffers] = useState<Offer[]>([]);
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
const {account} = useContext(AccountContext)
const [showDialog, setShowDialog] = useState<boolean>(false);
const closeDialog = () => {
setShowDialog(false);
}
const createOffer = async (property_id: number) => {
console.log(account)
if(account)
{
console.log({
user: account.id,
property: property_id,
})
response = await axiosInstance.post(`/offers/`,
{
user: account.id,
property: property_id,
})
setShowDialog(false);
setShowDialog(false)
}
}
const submitOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
response = await axiosInstance.put(`/offers/${offer_id}/`,
{
user: sender_id,
property: property_id,
status:'submitted'
})
console.log(response)
// TODO: update the selectedOffer' status
const updatedOffers: Offer[] = offers.map(item => ({
...item, // Spread operator to copy existing properties
status: 'submitted'
}));
setOffers(updatedOffers);
}
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
}
useEffect(() => {
const fetchOffers = async () => {
try{
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/')
console.log(data)
if (data.length > 0){
console.log(data)
const fetchedOffers: Offer[] = data.map(item => {
console.log(item)
return {
id: item.id,
sender: item.user.first_name + " " + item.user.last_name,
status: item.status,
address: item.property.address,
is_active: item.is_active,
lastMessageTimestamp: item.updated_at,
market_value: item.property.market_value,
offer_value: '100000',
sender_id: item.user.id,
property_id: item.property.id
}
})
console.log(fetchedOffers)
setOffers(fetchedOffers);
}
}catch(error){
}
}
fetchOffers();
}, [])
type offerChoice = 'accept' | 'counter' | 'reject';
const handleOffer = async (choice: offerChoice) => {
console.log(choice)
}
const selectedOffer = offers.find(
(conv) => conv.id === selectedOfferId
);
return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
<Grid container sx={{ height: '100%' }}>
{/* Left Panel: Offer List */}
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
<Stack direction="row" sx={{width:'100%'}}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
Offers
</Typography>
<Button
variant='contained'
color='primary'
sx={{ml:'auto'}}
onClick={() => setShowDialog(true)}
>
Create Offer
</Button>
</Stack>
</Box>
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
{offers.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
<Typography>No offers submited yet.</Typography>
</Box>
) : (
offers
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
.map((conv) => (
<ListItem
key={conv.id}
button
selected={selectedOfferId === conv.id}
onClick={() => setSelectedOfferId(conv.id)}
sx={{ py: 1.5, px: 2 }}
>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{conv.sender}
</Typography>
}
secondary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
{conv.address}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatTimestamp(conv.lastMessageTimestamp)}
</Typography>
</Box>
}
/>
</ListItem>
))
)}
</List>
</Grid>
{/* Right Panel: Offer Detail */}
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
{selectedOffer ? (
<>
{/* Offer Header */}
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar sx={{ bgcolor: 'purple.200'}}>
{selectedOffer.sender.split(' ').map(n => n[0]).join('')}
</Avatar>
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{selectedOffer.sender}
</Typography>
</Box>
{/* Messages Area */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
{/* add the offer details here */}
<Accordion>
<AccordionSummary>
Offer for {selectedOffer.address}
</AccordionSummary>
<AccordionDetails>
<Typography>
Offer Price: <strong>{selectedOffer.offer_value}</strong>
</Typography>
<Typography>
Waived Inspection: <strong>No</strong>
</Typography>
<Typography>
Closing date: <strong>90 days</strong>
</Typography>
<Typography>
Status: <strong>{selectedOffer.status}</strong>
</Typography>
</AccordionDetails>
<AccordionActions>
{selectedOffer.status === 'submitted' ? (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
color="primary"
onClick={async() => handleOffer('accept')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Accept
</Button>
<Button
variant="contained"
color="primary"
onClick={async() => handleOffer('counter')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Counter
</Button>
<Button
variant="contained"
color="primary"
onClick={ async() => handleOffer('reject')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Reject
</Button>
</Box>
): (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
color="primary"
onClick={async() => withdrawOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
endIcon={<DeleteForeverIcon />}
sx={{ px: 3, py: 1.2 }}
>
Withdraw Offer
</Button>
<Button
variant="contained"
color="primary"
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Submit
</Button>
</Box>
)}
</AccordionActions>
</Accordion>
</Box>
{/* Message Input */}
</>
) : (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6">Select an offer to view</Typography>
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
</Box>
)}
</Grid>
</Grid>
<CreateOfferDialog showDialog={showDialog} createOffer={createOffer} closeDialog={closeDialog} />
</Paper>
</Container>
);
}
export default Offers;

View File

@@ -0,0 +1,42 @@
import React, { useState, useEffect, useContext } from 'react';
import { Container, Typography, Box, Button, Divider, Paper, Alert, Grid } from '@mui/material';
import { PropertiesAPI, UserAPI } from 'types';
import AddPropertyDialog from 'components/sections/dashboard/Home/Profile/AddPropertyDialog';
import PropertyCard from 'components/sections/dashboard/Home/Profile/PropertyCard.';
import ProfileCard from 'components/sections/dashboard/Home/Profile/ProfileCard';
import { AccountContext } from 'contexts/AccountContext';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi';
import PropertyOwnerProfile from 'components/sections/dashboard/Home/Profile/PropertyOwnerProfile';
import VendorProfile from 'components/sections/dashboard/Home/Profile/VendorProfile';
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
import AttorneyProfile from 'components/sections/dashboard/Home/Profile/AttorneyProfile';
export type ProfileProps = {
account: UserAPI;
};
const ProfilePage: React.FC = () => {
const { account, accountLoading } = useContext(AccountContext);
if (accountLoading) {
return <DashboardLoading />;
}
if (!account) {
return <DashboardErrorPage />;
}
if (account.user_type === 'property_owner') {
return <PropertyOwnerProfile account={account} />;
} else if (account.user_type === 'vendor') {
return <VendorProfile account={account} />;
} else if (account.user_type === 'attorney') {
return <AttorneyProfile account={account} />;
} else if (account.user_type === 'real_estate_agent') {
return <>TODO</>;
//return (<VendorProfile account={account} />)
}
};
export default ProfilePage;

View File

@@ -1,60 +1,398 @@
import { ReactElement } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import Grid from '@mui/material/Unstable_Grid2';
import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
import PhotoGalleryCard from 'components/sections/dashboard/Home/Property/PhotoGalleryCard';
import MarketStatistics from 'components/sections/dashboard/Home/Property/MarketStatistics';
import PropertyListingCard from 'components/sections/dashboard/Home/Property/PropertyListingCard';
import LoanDetailsCard from 'components/sections/dashboard/Home/Property/LoanDetailsCard';
import PropertyValueGraphCard from 'components/sections/dashboard/Home/Property/PropertyValueGraphCard';
import React, { useState } from 'react';
import { Container, Typography, Box, Grid, Alert } from '@mui/material';
import { PropertiesAPI } from 'types';
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
const Property = (): ReactElement => {
return(
<Grid
container
component="main"
columns={12}
spacing={3.75}
flexGrow={1}
pt={4.375}
pr={1.875}
pb={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
pl: { xs: 3.75, lg: 0 },
}}
>
<Grid xs={12} md={8}>
<PropertyDetailsCard />
</Grid>
<Grid xs={12} md={4}>
<HomePriceEstimate />
</Grid>
<Grid xs={12} md={8}>
<PhotoGalleryCard />
</Grid>
<Grid xs={12} md={4}>
<MarketStatistics />
</Grid>
<Grid xs={12} md={8}>
<PropertyValueGraphCard />
</Grid>
<Grid xs={12} md={4}>
<LoanDetailsCard />
</Grid>
<Grid xs={12} md={4}>
<PropertyListingCard />
</Grid>
</Grid>
)
// Reusing the mockProperties from PropertyDetailPage for consistent data
const mockProperties: PropertiesAPI[] = [
{
id: 101,
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
address: '123 Main St',
city: 'Anytown',
state: 'CA',
zip_code: '90210',
market_value: '500000',
loan_amount: '300000',
loan_term: 30,
loan_start_date: '2020-05-01',
created_at: '2020-04-20',
last_updated: '2023-10-10',
pictures: [
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
'https://via.placeholder.com/600x400?text=Property+1+Living',
],
description: 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
sq_ft: 1800,
features: ['Garage', 'Central AC', 'Hardwood Floors'],
num_bedrooms: 3,
num_bathrooms: 2,
latitude: 34.0522, // Example coordinates for Los Angeles
longitude: -118.2437,
},
{
id: 102,
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
address: '456 Oak Ave',
city: 'Anytown',
state: 'CA',
zip_code: '90210',
market_value: '750000',
loan_amount: '500000',
loan_term: 20,
loan_start_date: '2022-01-10',
created_at: '2021-12-01',
last_updated: '2023-11-20',
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
sq_ft: 2500,
features: ['Pool', 'Fireplace', 'Large Yard'],
num_bedrooms: 4,
num_bathrooms: 3,
latitude: 34.075, // Another example coordinate
longitude: -118.30,
},
{
id: 103,
owner: { user: { id: 99, email: 'another.owner@example.com', first_name: 'Jane', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '987-654-3210' },
address: '789 Pine Lane',
city: 'Otherville',
state: 'NY',
zip_code: '10001',
market_value: '1200000',
loan_amount: '800000',
loan_term: 15,
loan_start_date: '2021-03-20',
created_at: '2021-02-15',
last_updated: '2024-01-05',
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
description: 'Luxury apartment in the heart of the city with stunning views.',
sq_ft: 1200,
features: ['City View', 'Gym Access', 'Doorman'],
num_bedrooms: 2,
num_bathrooms: 2,
latitude: 40.7128, // NYC
longitude: -74.0060,
},
{
id: 104,
owner: { user: { id: 100, email: 'test.user@example.com', first_name: 'Bob', last_name: 'Brown', user_type: 'property_owner', is_active: true, date_joined: '2024-01-01', tos_signed: true, profile_created: true, tier: 'premium' }, phone_number: '555-987-6543' },
address: '101 Elm Street',
city: 'Sampleton',
state: 'TX',
zip_code: '75001',
market_value: '350000',
loan_amount: '250000',
loan_term: 30,
loan_start_date: '2023-07-01',
created_at: '2023-06-20',
last_updated: '2024-06-15',
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
description: 'Cozy starter home with a large yard, ideal for families.',
sq_ft: 1500,
features: ['Large Yard', 'New Roof'],
num_bedrooms: 3,
num_bathrooms: 2,
latitude: 32.7767, // Dallas
longitude: -96.7970,
}
];
const Property: React.FC = () => {
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
const [initialLoad, setInitialLoad] = useState(true);
const filterProperties = (filters: any) => {
const filtered = mockProperties.filter(property => {
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
sqFtMatch && bedroomsMatch && bathroomsMatch;
});
setSearchResults(filtered);
setInitialLoad(false);
};
const handleSearch = (filters: any) => {
filterProperties(filters);
};
const handleClearSearch = () => {
setSearchResults([]); // Clear results on clear
setInitialLoad(true); // Reset to initial state
};
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Property Search
</Typography>
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
</Typography>
<Grid container spacing={2}>
{searchResults.length === 0 && !initialLoad ? (
<Grid item xs={12}>
<Alert severity="info">No properties found matching your criteria.</Alert>
</Grid>
) : (
searchResults.map(property => (
<Grid item xs={12} sm={6} md={4} key={property.id}>
<PropertyListItem
property={property}
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
/>
</Grid>
))
)}
</Grid>
</Container>
);
};
export default Property;
// import { ReactElement, useContext, useEffect, useState } from 'react';
// import { drawerWidth } from 'layouts/main-layout';
// import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
// import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
// import PhotoGalleryCard from 'components/sections/dashboard/Home/Property/PhotoGalleryCard';
// import MarketStatistics from 'components/sections/dashboard/Home/Property/MarketStatistics';
// import PropertyListingCard from 'components/sections/dashboard/Home/Property/PropertyListingCard';
// import LoanDetailsCard from 'components/sections/dashboard/Home/Property/LoanDetailsCard';
// import PropertyValueGraphCard from 'components/sections/dashboard/Home/Property/PropertyValueGraphCard';
// import {axiosInstance} from '../../axiosApi'
// import { PropertiesAPI } from 'types';
// import { AxiosResponse } from 'axios';
// import LoadingSkeleton from 'components/base/LoadingSkeleton';
// import { AccountContext } from 'contexts/AccountContext';
// import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack } from '@mui/material';
// import HouseIcon from '@mui/icons-material/House';
// import { formatTimestamp } from 'utils';
// const Property = (): ReactElement => {
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
// const {account, accountLoading} = useContext(AccountContext);
// if(accountLoading){
// return <LoadingSkeleton />
// }
// useEffect(() => {
// const fetchProperties = async() => {
// try{
// const {data, }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/')
// if(data.length > 0){
// setProperties(data);
// setSelectedPropertyId(data[0].id)
// }
// }catch(error){
// console.log(error)
// }
// }
// fetchProperties();
// }, [])
// const selectedProperty = properties.find(
// (property) => property.id === selectedPropertyId
// );
// return(
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
// <Grid container sx={{ minHeight: '100%' }}>
// {/* Left Panel: Conversation List */}
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
// <Stack direction="row" sx={{width:'100%'}}>
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
// Properties
// </Typography>
// </Stack>
// </Box>
// <List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
// {properties.length === 0 ? (
// <Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
// <HouseIcon sx={{ fontSize: 40, mb: 1 }} />
// <Typography>No properties yet. Go to profile to add one.</Typography>
// </Box>
// ) : (
// properties
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
// .map((conv) => (
// <ListItem
// key={conv.id}
// button
// selected={selectedPropertyId === conv.id}
// onClick={() => setSelectedPropertyId(conv.id)}
// sx={{ py: 1.5, px: 2 }}
// >
// <ListItemText
// primary={
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
// {conv.address}
// </Typography>
// }
// secondary={
// <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
// <Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
// {conv.market_value}
// </Typography>
// <Typography variant="caption" color="text.disabled">
// {formatTimestamp(conv.last_updated)}
// </Typography>
// </Box>
// }
// />
// </ListItem>
// ))
// )}
// </List>
// </Grid>
// {/* Right Panel: Conversation Detail */}
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
// {selectedProperty ? (
// <>
// {/* Conversation Header */}
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
// <Avatar sx={{ bgcolor: 'purple.200'}}>
// {/* {selectedProperty.withName.split(' ').map(n => n[0]).join('')} */}
// </Avatar>
// <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
// {selectedProperty.address}
// </Typography>
// </Box>
// {/* Messages Area */}
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
// <Grid
// container
// component="main"
// columns={12}
// spacing={3.75}
// flexGrow={1}
// pt={4.375}
// pr={1.875}
// pb={0}
// sx={{
// width: { md: `calc(100% - ${drawerWidth}px)` },
// pl: { xs: 3.75, lg: 0 },
// }}
// >
// <Grid xs={12} md={8}>
// <PropertyDetailsCard />
// </Grid>
// <Grid xs={12} md={4}>
// <HomePriceEstimate />
// </Grid>
// <Grid xs={12} md={8}>
// <PhotoGalleryCard />
// </Grid>
// <Grid xs={12} md={4}>
// <MarketStatistics />
// </Grid>
// <Grid xs={12} md={8}>
// <PropertyValueGraphCard />
// </Grid>
// <Grid xs={12} md={4}>
// <LoanDetailsCard />
// </Grid>
// <Grid xs={12} md={4}>
// <PropertyListingCard />
// </Grid>
// </Grid>
// </Box>
// </>
// ) : (
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
// <HouseIcon sx={{ fontSize: 80, mb: 2 }} />
// <Typography variant="h6">Select a property to manage</Typography>
// <Typography variant="body2">Click on a property from the left panel to get started.</Typography>
// </Box>
// )}
// </Grid>
// </Grid>
// </Paper>
// </Container>
// )
// // return(
// // <Grid
// // container
// // component="main"
// // columns={12}
// // spacing={3.75}
// // flexGrow={1}
// // pt={4.375}
// // pr={1.875}
// // pb={0}
// // sx={{
// // width: { md: `calc(100% - ${drawerWidth}px)` },
// // pl: { xs: 3.75, lg: 0 },
// // }}
// // >
// // <Grid xs={12} md={8}>
// // <PropertyDetailsCard />
// // </Grid>
// // <Grid xs={12} md={4}>
// // <HomePriceEstimate />
// // </Grid>
// // <Grid xs={12} md={8}>
// // <PhotoGalleryCard />
// // </Grid>
// // <Grid xs={12} md={4}>
// // <MarketStatistics />
// // </Grid>
// // <Grid xs={12} md={8}>
// // <PropertyValueGraphCard />
// // </Grid>
// // <Grid xs={12} md={4}>
// // <LoanDetailsCard />
// // </Grid>
// // <Grid xs={12} md={4}>
// // <PropertyListingCard />
// // </Grid>
// // </Grid>
// // )
// }
// export default Property;

View File

@@ -0,0 +1,200 @@
import React, { useState, useEffect, useContext } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material';
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types';
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseCard';
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi';
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
import { AccountContext } from 'contexts/AccountContext';
const PropertyDetailPage: React.FC = () => {
// In a real app, you'd get propertyId from URL params or a global state
const { account, accountLoading } = useContext(AccountContext);
const { propertyId } = useParams<{ propertyId: string }>();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const isSearch = searchParams.get('search') === '1';
const [property, setProperty] = useState<PropertiesAPI | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
if (accountLoading) {
return <>Page is loading</>;
} else if (!accountLoading && !account) {
return <>There was an error</>;
} else {
useEffect(() => {
// Simulate API call
const getProperty = async () => {
try {
setLoading(true);
setError(null);
const url = isSearch
? `/properties/${propertyId}/?search=1`
: `/properties/${propertyId}/`;
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
if (isSearch) {
// kick the view count
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
}
if (data !== undefined) {
setProperty(data);
}
} catch {
setError('Property not found.');
} finally {
setLoading(false);
}
};
getProperty();
}, [propertyId]);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property
console.log('Saving property:', updatedProperty);
setProperty(updatedProperty);
setMessage({ type: 'success', text: 'Property details updated successfully!' });
setTimeout(() => setMessage(null), 3000);
};
const onStatusChange = () => {
if (property) {
setProperty((property) => ({ ...property, status: 'active' }));
}
};
const onSavedPropertySave = async () => {
try {
const response = await axiosInstance.post(`/saved-properties/`, {
property: property.id,
user: account.id,
});
console.log(response);
} catch (error) {
console.log(error);
}
};
const handleDeleteProperty = (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
};
const handleOfferSubmit = (offerAmount: number) => {
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`);
// Here you would send the offer to your backend API
};
if (loading) {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
<CircularProgress />
<Typography>Loading property details...</Typography>
</Container>
);
}
if (error) {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Alert severity="error">{error}</Alert>
</Container>
);
}
if (!property) {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Alert severity="info">No property data available.</Alert>
</Container>
);
}
// Determine if the current user is the owner of this property
const isOwnerOfProperty = account.id === property.owner.user.id;
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
let listed_price: string;
if (property.listed_price === undefined || property.listed_price === null) {
listed_price = 'No Price';
} else {
listed_price = parseFloat(property.listed_price).toString();
}
console.log(property);
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
<Grid container spacing={3}>
{/* Main Property Details */}
<Grid item xs={12} md={8}>
<PropertyDetailCard
property={property}
isPublicPage={true}
onSave={handleSaveProperty}
isOwnerView={isOwnerOfProperty}
onDelete={handleDeleteProperty}
/>
</Grid>
{/* Status, Cost, Offers */}
<Grid item xs={12} md={4}>
<Grid container spacing={3}>
<Grid item xs={12}>
<PropertyStatusCard
property={property}
isOwner={isOwnerOfProperty}
onStatusChange={onStatusChange}
onSavedPropertySave={onSavedPropertySave}
/>
</Grid>
<Grid item xs={12}>
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
</Grid>
<Grid item xs={12}>
<OfferSubmissionCard
onOfferSubmit={handleOfferSubmit}
listingStatus={property.property_status}
/>
</Grid>
<Grid item xs={12}>
<OpenHouseCard openHouses={property.open_houses} />
</Grid>
</Grid>
</Grid>
{/* Additional Information */}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid item xs={12} md={4}>
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
</Grid>
<Grid item xs={12} md={4}>
<WalkScoreCard walkScore={property.walk_score} />
</Grid>
<Grid item xs={12} md={4}>
{property.schools && <SchoolCard schools={property.schools} />}
</Grid>
</Grid>
</Container>
);
}
};
export default PropertyDetailPage;

View File

@@ -0,0 +1,170 @@
import React, { useEffect, useState } from 'react';
import { Container, Typography, Box, Grid, Alert } from '@mui/material';
import { PropertiesAPI } from 'types';
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
import { Navigate, useNavigate } from 'react-router-dom';
import MapSerachComponent from 'components/base/MapSearchComponent';
import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi';
import { useMapsLibrary } from '@vis.gl/react-google-maps';
const PropertySearchPage: React.FC = () => {
const navigate = useNavigate();
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
const [initialLoad, setInitialLoad] = useState(true);
const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null);
const [mapState, setMapState] = useState({
center: { lat: 39.8283, lng: -98.5795 }, // Center of the US
zoom: 4,
});
useEffect(() => {
const fetchProperties = async () => {
try {
const { data }: AxiosResponse<PropertiesAPI[]> =
await axiosInstance.get(`/properties/?search=1`);
console.log(data);
data.map((item) => {
console.log(item);
});
if (data !== undefined) {
setSearchResults(data);
}
} catch {
} finally {
setInitialLoad(true);
}
};
fetchProperties();
}, []);
const filterProperties = async (filters: any) => {
const searchParams = new URLSearchParams();
for (const key in filters) {
if (filters.hasOwnProperty(key)) {
const value = filters[key];
// Exclude attributes that don't have values (null, undefined, empty string)
if (value !== null && value !== undefined && value !== '') {
searchParams.append(key, String(value)); // Ensure value is a string
}
}
}
const queryString = searchParams.toString();
console.log(queryString);
try {
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(
`/properties/?search=1&${queryString}`,
);
console.log(data);
setSearchResults(data);
} catch {
} finally {
setInitialLoad(false);
}
};
const handleSearch = async (filters: any) => {
console.log(filters);
await filterProperties(filters);
};
const handleClearSearch = () => {
setSearchResults([]); // Clear results on clear
setInitialLoad(true); // Reset to initial state
};
const handleMapBoundsChange = ({ center, zoom, bounds }: any) => {
setMapState({ center, zoom });
// Optional: you could filter search results based on the map bounds
};
const handleBoxDrawn = (bounds: any) => {
const filtered = mockProperties.filter((p) => {
if (!p.latitude || !p.longitude) return false;
return (
p.latitude <= bounds.ne.lat &&
p.latitude >= bounds.sw.lat &&
p.longitude <= bounds.ne.lng &&
p.longitude >= bounds.sw.lng
);
});
setSearchResults(filtered);
setInitialLoad(false);
// Optional: Adjust map zoom to fit the new search results
// You'd need to calculate the new center and zoom based on the filtered results.
};
const handleMarkerClick = (propertyId: number) => {
navigate(`/properties/${propertyId}`);
};
const handleMarkerHover = (property: PropertiesAPI) => {
setSelectedPropertyId(property.id);
};
const handleMarkerUnhover = () => {
setSelectedPropertyId(null);
};
// Handler for a map marker click, this will navigate to the details page
const handleMapMarkerClick = (propertyId: number) => {
navigate(`/properties/${propertyId}/&search=1`);
};
console.log(searchResults);
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
Property Search & Map
</Typography>
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
<Grid container spacing={3}>
{/* Property List Section */}
<Grid item xs={12} md={6}>
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
</Typography>
{searchResults.length === 0 ? (
<Alert severity="info">No properties found matching your criteria.</Alert>
) : (
searchResults.map((property) => (
<PropertyListItem
key={property.id}
property={property}
onHover={handleMarkerHover}
onUnhover={handleMarkerUnhover}
// The click logic will now be handled by the marker
// We just highlight the selected marker
isSelected={selectedPropertyId === property.id}
/>
))
)}
</Box>
</Grid>
{/* Map Section */}
<Grid item xs={12} md={6}>
<MapSerachComponent
center={mapState.center}
zoom={mapState.zoom}
properties={searchResults}
selectedPropertyId={selectedPropertyId}
onBoundsChanged={handleMapBoundsChange}
onBoxDrawn={handleBoxDrawn}
onMarkerClick={handleMapMarkerClick} // Pass the navigation handler
onMarkerHover={handleMarkerHover}
onMarkerUnhover={handleMarkerUnhover}
/>
</Grid>
</Grid>
</Container>
);
};
export default PropertySearchPage;

View File

@@ -0,0 +1,238 @@
import {
Alert,
Box,
Button,
Container,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { ReactElement, useEffect, useMemo, useState } from 'react';
const AmoritizationTable = (): ReactElement => {
const [principal, setPrincipal] = useState<number | ''>('');
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
const [loanTermYears, setLoanTermYears] = useState<number | ''>('');
const [amortizationSchedule, setAmortizationSchedule] = useState<AmortizationRow[]>([]);
const [monthlyPayment, setMonthlyPayment] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Function to calculate the amortization schedule
const calculateAmortization = () => {
// Reset error message
setError(null);
// Input validation
if (principal === '' || annualInterestRate === '' || loanTermYears === '') {
setError('Please fill in all fields.');
return;
}
if (principal <= 0) {
setError('Principal must be greater than 0.');
return;
}
if (annualInterestRate < 0) {
setError('Interest rate cannot be negative.');
return;
}
if (loanTermYears <= 0) {
setError('Loan term must be greater than 0.');
return;
}
const p = Number(principal);
const annualRate = Number(annualInterestRate);
const years = Number(loanTermYears);
const monthlyRate = annualRate / 100 / 12; // Convert annual percentage rate to monthly decimal rate
const numberOfPayments = years * 12; // Total number of monthly payments
let calculatedMonthlyPayment = 0;
// Calculate monthly payment using the loan amortization formula
if (monthlyRate === 0) {
// If interest rate is 0, monthly payment is just principal / number of payments
calculatedMonthlyPayment = p / numberOfPayments;
} else {
calculatedMonthlyPayment =
(p * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
}
setMonthlyPayment(calculatedMonthlyPayment);
const schedule: AmortizationRow[] = [];
let currentBalance = p;
// Generate amortization schedule month by month
for (let i = 1; i <= numberOfPayments; i++) {
const interestPaid = currentBalance * monthlyRate;
let principalPaid = calculatedMonthlyPayment - interestPaid;
// Adjust last payment to account for potential rounding errors
if (i === numberOfPayments) {
principalPaid = currentBalance; // Pay off the remaining balance
calculatedMonthlyPayment = interestPaid + principalPaid; // Adjust last payment amount
}
const endingBalance = currentBalance - principalPaid;
schedule.push({
month: i,
startingBalance: currentBalance,
monthlyPayment: calculatedMonthlyPayment,
interestPaid: interestPaid,
principalPaid: principalPaid,
endingBalance: endingBalance < 0 ? 0 : endingBalance, // Ensure ending balance doesn't go negative
});
currentBalance = endingBalance;
}
setAmortizationSchedule(schedule);
};
// Memoize the total interest paid for display
const totalInterestPaid = useMemo(() => {
return amortizationSchedule.reduce((sum, row) => sum + row.interestPaid, 0);
}, [amortizationSchedule]);
// Memoize the total principal paid (should be equal to initial principal)
const totalPrincipalPaid = useMemo(() => {
return amortizationSchedule.reduce((sum, row) => sum + row.principalPaid, 0);
}, [amortizationSchedule]);
// Memoize the total cost of the loan
const totalCostOfLoan = useMemo(() => {
return monthlyPayment * (loanTermYears === '' ? 0 : Number(loanTermYears) * 12);
}, [monthlyPayment, loanTermYears]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ my: 4, textAlign: 'center' }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
Loan Amortization Calculator
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Calculate your loan payments and see the amortization schedule.
</Typography>
</Box>
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
<Box
component="form"
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 3,
mb: 3,
}}
noValidate
autoComplete="off"
>
<TextField
label="Principal Loan Amount ($)"
type="number"
value={principal}
onChange={(e) => setPrincipal(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Annual Interest Rate (%)"
type="number"
value={annualInterestRate}
onChange={(e) =>
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Loan Term (Years)"
type="number"
value={loanTermYears}
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
</Box>
<Button
variant="contained"
color="primary"
onClick={calculateAmortization}
fullWidth
size="large"
>
Calculate Amortization
</Button>
{error && (
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
</Paper>
{amortizationSchedule.length > 0 && (
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
Summary
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="h6">Monthly Payment: ${monthlyPayment.toFixed(2)}</Typography>
<Typography variant="body1">
Total Principal Paid: ${totalPrincipalPaid.toFixed(2)}
</Typography>
<Typography variant="body1">
Total Interest Paid: ${totalInterestPaid.toFixed(2)}
</Typography>
<Typography variant="body1">
Total Cost of Loan: ${totalCostOfLoan.toFixed(2)}
</Typography>
</Box>
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
Amortization Schedule
</Typography>
<TableContainer component={Paper} sx={{ maxHeight: 600, overflow: 'auto' }}>
<Table stickyHeader aria-label="amortization table">
<TableHead>
<TableRow>
<TableCell>Month</TableCell>
<TableCell align="right">Starting Balance</TableCell>
<TableCell align="right">Monthly Payment</TableCell>
<TableCell align="right">Interest Paid</TableCell>
<TableCell align="right">Principal Paid</TableCell>
<TableCell align="right">Ending Balance</TableCell>
</TableRow>
</TableHead>
<TableBody>
{amortizationSchedule.map((row) => (
<TableRow key={row.month}>
<TableCell component="th" scope="row">
{row.month}
</TableCell>
<TableCell align="right">${row.startingBalance.toFixed(2)}</TableCell>
<TableCell align="right">${row.monthlyPayment.toFixed(2)}</TableCell>
<TableCell align="right">${row.interestPaid.toFixed(2)}</TableCell>
<TableCell align="right">${row.principalPaid.toFixed(2)}</TableCell>
<TableCell align="right">${row.endingBalance.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</Container>
);
};
export default AmoritizationTable;

View File

@@ -0,0 +1,392 @@
import {
Alert,
Box,
Button,
Container,
InputAdornment,
Paper,
Slider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { ReactElement, useEffect, useMemo, useState } from 'react';
const HomeAffordability = (): ReactElement => {
// State variables for affordability inputs
const [annualIncome, setAnnualIncome] = useState<number | ''>('');
const [downPayment, setDownPayment] = useState<number | ''>('');
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
const [loanTermYears, setLoanTermYears] = useState<number | ''>(30); // Default to 30 years
const [pmiRate, setPmiRate] = useState<number | ''>(0.5); // Default PMI rate as percentage
const [propertyTaxRate, setPropertyTaxRate] = useState<number | ''>(1.2); // Default annual property tax rate as percentage
const [homeInsuranceAnnual, setHomeInsuranceAnnual] = useState<number | ''>(1200); // Default annual home insurance
const [otherMonthlyDebts, setOtherMonthlyDebts] = useState<number | ''>(0); // e.g., car payments, student loans
const [maxDebtToIncomeRatio, setMaxDebtToIncomeRatio] = useState<number>(36); // Default DTI ratio as percentage
// State variables for calculated results
const [affordableHomePrice, setAffordableHomePrice] = useState<number>(0);
const [estimatedMonthlyPayment, setEstimatedMonthlyPayment] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Function to calculate home affordability
const calculateAffordability = () => {
// Reset error message and previous results
setError(null);
setAffordableHomePrice(0);
setEstimatedMonthlyPayment(0);
// Input validation
if (
annualIncome === '' ||
annualInterestRate === '' ||
loanTermYears === '' ||
downPayment === ''
) {
setError('Please fill in Annual Income, Down Payment, Annual Interest Rate, and Loan Term.');
return;
}
if (Number(annualIncome) <= 0) {
setError('Annual Income must be greater than 0.');
return;
}
if (Number(downPayment) < 0) {
setError('Down Payment cannot be negative.');
return;
}
if (Number(annualInterestRate) < 0) {
setError('Annual Interest Rate cannot be negative.');
return;
}
if (Number(loanTermYears) <= 0) {
setError('Loan Term must be greater than 0.');
return;
}
if (Number(pmiRate) < 0 || Number(pmiRate) > 100) {
setError('PMI Rate must be between 0 and 100.');
return;
}
if (Number(propertyTaxRate) < 0 || Number(propertyTaxRate) > 100) {
setError('Property Tax Rate must be between 0 and 100.');
return;
}
if (Number(homeInsuranceAnnual) < 0) {
setError('Annual Home Insurance cannot be negative.');
return;
}
if (Number(otherMonthlyDebts) < 0) {
setError('Other Monthly Debts cannot be negative.');
return;
}
if (Number(maxDebtToIncomeRatio) <= 0 || Number(maxDebtToIncomeRatio) > 100) {
setError('Max Debt-to-Income Ratio must be between 1 and 100.');
return;
}
const income = Number(annualIncome);
const dp = Number(downPayment);
const annualRate = Number(annualInterestRate);
const years = Number(loanTermYears);
const pmiPercent = Number(pmiRate) / 100;
const taxPercent = Number(propertyTaxRate) / 100;
const insuranceAnnual = Number(homeInsuranceAnnual);
const otherDebts = Number(otherMonthlyDebts);
const maxDTI = Number(maxDebtToIncomeRatio) / 100;
const monthlyIncome = income / 12;
const maxMonthlyHousingPayment = monthlyIncome * maxDTI - otherDebts;
if (maxMonthlyHousingPayment <= 0) {
setError(
'Your calculated maximum affordable monthly housing payment is zero or negative. Adjust inputs.',
);
return;
}
// Estimate affordable home price iteratively or using a more complex formula
// For simplicity, we'll use an iterative approach or a target monthly payment
// Let's assume the maximum monthly housing payment is the target for PITI.
// PITI = Principal & Interest + Property Tax + Home Insurance + PMI
// We need to find the Loan Amount (L) such that PITI is <= maxMonthlyHousingPayment
// L = Home Price - Down Payment
// P&I = L * (monthlyRate / (1 - (1 + monthlyRate)^-numPayments))
// Property Tax (monthly) = Home Price * (annualTaxRate / 12)
// Home Insurance (monthly) = Annual Insurance / 12
// PMI (monthly) = L * (pmiRate / 12) -- typically based on loan amount, not home price
// This becomes a bit of an algebraic challenge to solve directly for Home Price.
// A common approach is to solve for the maximum loan amount first, then add the down payment.
const monthlyRate = annualRate / 100 / 12;
const numberOfPayments = years * 12;
// We need to estimate the maximum loan amount (L) that can be supported by maxMonthlyHousingPayment
// Let's re-frame:
// Max monthly payment for P&I + PMI = maxMonthlyHousingPayment - (Monthly Tax + Monthly Insurance)
const monthlyInsurance = insuranceAnnual / 12;
// We can't calculate monthly tax directly from home price yet, so we'll need to iterate or estimate.
// Let's assume a starting guess for the affordable home price and refine.
// Or, work backwards from the maximum monthly payment.
// Option 1: Work backwards from max monthly housing payment to find max loan amount
// M = P * [ i(1 + i)^n ] / [ (1 + i)^n 1]
// P = M / [ i(1 + i)^n ] / [ (1 + i)^n 1]
// Here, M is the portion of the maxMonthlyHousingPayment that can go towards P&I.
// This is tricky because Tax and PMI depend on the home price/loan amount.
// Let's use an iterative approach to find the affordable home price.
let estimatedHomePrice = 0;
let high = 5000000; // Upper bound for home price search
let low = 10000; // Lower bound
let iterations = 100; // Number of iterations for binary search
while (iterations > 0 && high - low > 0.01) {
// Iterate until precision is met
const midPrice = (low + high) / 2;
const loan = midPrice - dp;
if (loan <= 0) {
// If loan is not positive, this price is too low
low = midPrice;
iterations--;
continue;
}
let estimatedPI = 0;
if (monthlyRate === 0) {
estimatedPI = loan / numberOfPayments;
} else {
estimatedPI = (loan * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
}
const estimatedMonthlyTax = midPrice * (taxPercent / 12);
const estimatedMonthlyPMI = loan * (pmiPercent / 12); // PMI usually on loan amount
const currentEstimatedTotalMonthlyPayment =
estimatedPI + estimatedMonthlyTax + monthlyInsurance + estimatedMonthlyPMI;
if (currentEstimatedTotalMonthlyPayment <= maxMonthlyHousingPayment) {
estimatedHomePrice = midPrice;
low = midPrice; // Try for a higher price
} else {
high = midPrice; // Price is too high, reduce it
}
iterations--;
}
setAffordableHomePrice(estimatedHomePrice);
// Calculate the estimated monthly payment for the affordable price
const finalLoanAmount = estimatedHomePrice - dp;
let finalPI = 0;
if (monthlyRate === 0) {
finalPI = finalLoanAmount / numberOfPayments;
} else {
finalPI =
(finalLoanAmount * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
}
const finalMonthlyTax = estimatedHomePrice * (taxPercent / 12);
const finalMonthlyPMI = finalLoanAmount * (pmiPercent / 12);
const finalEstimatedMonthlyPayment =
finalPI + finalMonthlyTax + monthlyInsurance + finalMonthlyPMI;
setEstimatedMonthlyPayment(finalEstimatedMonthlyPayment);
if (estimatedHomePrice === 0 && maxMonthlyHousingPayment > 0) {
setError(
'Could not find an affordable home price with the given inputs. Try adjusting values.',
);
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ my: 4, textAlign: 'center' }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
Home Affordability Calculator
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Estimate how much home you can afford based on your income and debts.
</Typography>
</Box>
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
<Box
component="form"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
gap: 3,
mb: 3,
}}
noValidate
autoComplete="off"
>
{/* Income and Down Payment */}
<TextField
label="Annual Income ($)"
type="number"
value={annualIncome}
onChange={(e) => setAnnualIncome(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Down Payment ($)"
type="number"
value={downPayment}
onChange={(e) => setDownPayment(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
{/* Loan Details */}
<TextField
label="Annual Interest Rate (%)"
type="number"
value={annualInterestRate}
onChange={(e) =>
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
/>
<TextField
label="Loan Term (Years)"
type="number"
value={loanTermYears}
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
{/* Other Costs */}
<TextField
label="PMI Rate (Annual % of Loan Amount)"
type="number"
value={pmiRate}
onChange={(e) => setPmiRate(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0, max: 100 }}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
/>
<TextField
label="Property Tax Rate (Annual % of Home Price)"
type="number"
value={propertyTaxRate}
onChange={(e) =>
setPropertyTaxRate(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0, max: 100 }}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
/>
<TextField
label="Annual Home Insurance ($)"
type="number"
value={homeInsuranceAnnual}
onChange={(e) =>
setHomeInsuranceAnnual(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Other Monthly Debts ($)"
type="number"
value={otherMonthlyDebts}
onChange={(e) =>
setOtherMonthlyDebts(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
{/* Debt-to-Income Ratio */}
<Box sx={{ gridColumn: { xs: 'span 1', md: 'span 2' }, mt: 2 }}>
<Typography gutterBottom>Max Debt-to-Income Ratio (%)</Typography>
<Slider
value={typeof maxDebtToIncomeRatio === 'number' ? maxDebtToIncomeRatio : 0}
onChange={(_, newValue) => setMaxDebtToIncomeRatio(newValue as number)}
aria-labelledby="input-slider"
valueLabelDisplay="auto"
step={1}
marks
min={10}
max={50}
sx={{ width: '95%', margin: '0 auto' }}
/>
<TextField
value={maxDebtToIncomeRatio}
onChange={(e) => setMaxDebtToIncomeRatio(Number(e.target.value))}
type="number"
inputProps={{
step: 1,
min: 10,
max: 50,
'aria-labelledby': 'input-slider',
}}
sx={{ width: '100px', ml: 'auto' }}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
/>
</Box>
</Box>
<Button
variant="contained"
color="primary"
onClick={calculateAffordability}
fullWidth
size="large"
>
Calculate Affordability
</Button>
{error && (
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
</Paper>
{affordableHomePrice > 0 && (
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
Affordability Summary
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" color="primary">
Estimated Affordable Home Price: ${affordableHomePrice.toFixed(2)}
</Typography>
<Typography variant="body1" sx={{ mt: 1 }}>
Estimated Monthly Payment (PITI + PMI): ${estimatedMonthlyPayment.toFixed(2)}
</Typography>
<Typography variant="body2" sx={{ mt: 2 }}>
*This calculation is an estimate based on your inputs and a common debt-to-income
ratio approach. Actual affordability may vary based on lender criteria, credit score,
and other factors.
</Typography>
</Box>
</Paper>
)}
</Container>
);
};
export default HomeAffordability;

View File

@@ -0,0 +1,292 @@
import {
Alert,
Box,
Button,
Container,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { ReactElement, useEffect, useMemo, useState } from 'react';
const MortgageCalculator = (): ReactElement => {
// State variables for mortgage inputs
const [loanAmount, setLoanAmount] = useState<number | ''>('');
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
const [loanTermYears, setLoanTermYears] = useState<number | ''>('');
const [pmi, setPmi] = useState<number | ''>(''); // Private Mortgage Insurance (monthly)
const [hoaFees, setHoaFees] = useState<number | ''>(''); // Homeowners Association Fees (monthly)
const [homeInsurance, setHomeInsurance] = useState<number | ''>(''); // Home Insurance (monthly)
const [propertyTax, setPropertyTax] = useState<number | ''>(''); // Property Tax (annual)
// State variables for calculated results
const [monthlyPrincipalInterest, setMonthlyPrincipalInterest] = useState<number>(0);
const [totalMonthlyPayment, setTotalMonthlyPayment] = useState<number>(0);
const [totalLoanCost, setTotalLoanCost] = useState<number>(0);
const [totalPrincipalPaid, setTotalPrincipalPaid] = useState<number>(0);
const [totalInterestPaid, setTotalInterestPaid] = useState<number>(0);
const [totalPmiPaid, setTotalPmiPaid] = useState<number>(0);
const [totalHoaFeesPaid, setTotalHoaFeesPaid] = useState<number>(0);
const [totalHomeInsurancePaid, setTotalHomeInsurancePaid] = useState<number>(0);
const [totalPropertyTaxPaid, setTotalPropertyTaxPaid] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Function to calculate the mortgage costs
const calculateMortgage = () => {
// Reset error message and previous results
setError(null);
setMonthlyPrincipalInterest(0);
setTotalMonthlyPayment(0);
setTotalLoanCost(0);
setTotalPrincipalPaid(0);
setTotalInterestPaid(0);
setTotalPmiPaid(0);
setTotalHoaFeesPaid(0);
setTotalHomeInsurancePaid(0);
setTotalPropertyTaxPaid(0);
// Input validation
if (loanAmount === '' || annualInterestRate === '' || loanTermYears === '') {
setError('Please fill in Loan Amount, Annual Interest Rate, and Loan Term.');
return;
}
if (loanAmount <= 0) {
setError('Loan Amount must be greater than 0.');
return;
}
if (annualInterestRate < 0) {
setError('Annual Interest Rate cannot be negative.');
return;
}
if (loanTermYears <= 0) {
setError('Loan Term must be greater than 0.');
return;
}
const p = Number(loanAmount);
const annualRate = Number(annualInterestRate);
const years = Number(loanTermYears);
const monthlyRate = annualRate / 100 / 12; // Convert annual percentage rate to monthly decimal rate
const numberOfPayments = years * 12; // Total number of monthly payments
let calculatedMonthlyPI = 0; // Principal & Interest payment
// Calculate monthly Principal & Interest payment using the loan amortization formula
if (monthlyRate === 0) {
// If interest rate is 0, monthly P&I is just principal / number of payments
calculatedMonthlyPI = p / numberOfPayments;
} else {
calculatedMonthlyPI = (p * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
}
setMonthlyPrincipalInterest(calculatedMonthlyPI);
// Calculate total costs over the loan term
const totalPrincipal = p;
let totalInterest = 0;
let currentBalance = p;
// Simulate interest accumulation for total interest calculation
for (let i = 1; i <= numberOfPayments; i++) {
const interestForMonth = currentBalance * monthlyRate;
totalInterest += interestForMonth;
const principalPaidForMonth = calculatedMonthlyPI - interestForMonth;
currentBalance -= principalPaidForMonth;
if (currentBalance < 0) currentBalance = 0; // Prevent negative balance due to rounding
}
// Ensure total interest is non-negative and principal is accurate
totalInterest = Math.max(0, totalInterest);
// Recalculate total interest based on total payments if needed for precision
// totalInterest = (calculatedMonthlyPI * numberOfPayments) - p;
const monthlyPmi = pmi === '' ? 0 : Number(pmi);
const monthlyHoa = hoaFees === '' ? 0 : Number(hoaFees);
const monthlyInsurance = homeInsurance === '' ? 0 : Number(homeInsurance);
const monthlyPropertyTax = propertyTax === '' ? 0 : Number(propertyTax) / 12; // Convert annual to monthly
// Calculate total monthly payment including all fees
const calculatedTotalMonthlyPayment =
calculatedMonthlyPI + monthlyPmi + monthlyHoa + monthlyInsurance + monthlyPropertyTax;
setTotalMonthlyPayment(calculatedTotalMonthlyPayment);
// Calculate total costs over the entire loan term
const totalPaymentsCount = numberOfPayments; // Total months
const totalPmi = monthlyPmi * totalPaymentsCount;
const totalHoa = monthlyHoa * totalPaymentsCount;
const totalInsurance = monthlyInsurance * totalPaymentsCount;
const totalPropertyTax = monthlyPropertyTax * totalPaymentsCount;
setTotalPrincipalPaid(totalPrincipal);
setTotalInterestPaid(totalInterest);
setTotalPmiPaid(totalPmi);
setTotalHoaFeesPaid(totalHoa);
setTotalHomeInsurancePaid(totalInsurance);
setTotalPropertyTaxPaid(totalPropertyTax);
const calculatedTotalLoanCost =
totalPrincipal + totalInterest + totalPmi + totalHoa + totalInsurance + totalPropertyTax;
setTotalLoanCost(calculatedTotalLoanCost);
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ my: 4, textAlign: 'center' }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
Mortgage Cost Calculator
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Estimate your total monthly mortgage payment and overall loan cost.
</Typography>
</Box>
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
<Box
component="form"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
gap: 3,
mb: 3,
}}
noValidate
autoComplete="off"
>
{/* Loan Details */}
<TextField
label="Loan Amount ($)"
type="number"
value={loanAmount}
onChange={(e) => setLoanAmount(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Annual Interest Rate (%)"
type="number"
value={annualInterestRate}
onChange={(e) =>
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Loan Term (Years)"
type="number"
value={loanTermYears}
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
{/* Additional Monthly Costs */}
<TextField
label="PMI (Monthly $)"
type="number"
value={pmi}
onChange={(e) => setPmi(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="HOA Fees (Monthly $)"
type="number"
value={hoaFees}
onChange={(e) => setHoaFees(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Home Insurance (Monthly $)"
type="number"
value={homeInsurance}
onChange={(e) => setHomeInsurance(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
<TextField
label="Property Tax (Annual $)"
type="number"
value={propertyTax}
onChange={(e) => setPropertyTax(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
/>
</Box>
<Button
variant="contained"
color="primary"
onClick={calculateMortgage}
fullWidth
size="large"
>
Calculate Mortgage Cost
</Button>
{error && (
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
</Paper>
{totalMonthlyPayment > 0 && (
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
Mortgage Summary
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" color="primary">
Estimated Monthly Payment: ${totalMonthlyPayment.toFixed(2)}
</Typography>
<Typography variant="body1" sx={{ mt: 2, fontWeight: 'bold' }}>
Total Cost Breakdown Over {loanTermYears} Years:
</Typography>
<Typography variant="body2">
Total Principal: ${totalPrincipalPaid.toFixed(2)}
</Typography>
<Typography variant="body2">Total Interest: ${totalInterestPaid.toFixed(2)}</Typography>
{pmi !== '' && Number(pmi) > 0 && (
<Typography variant="body2">Total PMI: ${totalPmiPaid.toFixed(2)}</Typography>
)}
{hoaFees !== '' && Number(hoaFees) > 0 && (
<Typography variant="body2">
Total HOA Fees: ${totalHoaFeesPaid.toFixed(2)}
</Typography>
)}
{homeInsurance !== '' && Number(homeInsurance) > 0 && (
<Typography variant="body2">
Total Home Insurance: ${totalHomeInsurancePaid.toFixed(2)}
</Typography>
)}
{propertyTax !== '' && Number(propertyTax) > 0 && (
<Typography variant="body2">
Total Property Tax: ${totalPropertyTaxPaid.toFixed(2)}
</Typography>
)}
<Typography variant="h6" sx={{ mt: 2 }}>
Overall Loan Cost: ${totalLoanCost.toFixed(2)}
</Typography>
</Box>
</Paper>
)}
</Container>
);
};
export default MortgageCalculator;

View File

@@ -0,0 +1,292 @@
import {
Alert,
Box,
Button,
Container,
InputAdornment,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { ReactElement, useEffect, useMemo, useState } from 'react';
const NetTermsSheet = (): ReactElement => {
// State variables for home sale inputs
const [salePrice, setSalePrice] = useState<number | ''>('');
const [mortgagePayoff, setMortgagePayoff] = useState<number | ''>('');
const [agentCommissionRate, setAgentCommissionRate] = useState<number | ''>(6); // Default 6%
const [titleInsuranceOwner, setTitleInsuranceOwner] = useState<number | ''>(0);
const [escrowFees, setEscrowFees] = useState<number | ''>(0);
const [recordingFees, setRecordingFees] = useState<number | ''>(0);
const [transferTaxes, setTransferTaxes] = useState<number | ''>(0);
const [attorneyFees, setAttorneyFees] = useState<number | ''>(0);
const [proratedPropertyTaxes, setProratedPropertyTaxes] = useState<number | ''>(0);
const [proratedHoaDues, setProratedHoaDues] = useState<number | ''>(0);
const [sellerConcessions, setSellerConcessions] = useState<number | ''>(0);
const [otherFees, setOtherFees] = useState<number | ''>(0);
// State variables for calculated results
const [totalClosingCosts, setTotalClosingCosts] = useState<number>(0);
const [netProceeds, setNetProceeds] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
// Function to calculate the closing costs and net proceeds
const calculateNetTerms = () => {
// Reset error message and previous results
setError(null);
setTotalClosingCosts(0);
setNetProceeds(0);
// Input validation
if (salePrice === '') {
setError('Please enter the Sale Price.');
return;
}
if (Number(salePrice) <= 0) {
setError('Sale Price must be greater than 0.');
return;
}
if (Number(mortgagePayoff) < 0) {
setError('Mortgage Payoff cannot be negative.');
return;
}
if (Number(agentCommissionRate) < 0 || Number(agentCommissionRate) > 100) {
setError('Agent Commission Rate must be between 0 and 100.');
return;
}
const price = Number(salePrice);
const payoff = mortgagePayoff === '' ? 0 : Number(mortgagePayoff);
const commissionRate = Number(agentCommissionRate) / 100; // Convert to decimal
// Calculate agent commission
const agentCommission = price * commissionRate;
// Sum up all other closing costs
const otherFixedCosts =
(titleInsuranceOwner === '' ? 0 : Number(titleInsuranceOwner)) +
(escrowFees === '' ? 0 : Number(escrowFees)) +
(recordingFees === '' ? 0 : Number(recordingFees)) +
(transferTaxes === '' ? 0 : Number(transferTaxes)) +
(attorneyFees === '' ? 0 : Number(attorneyFees)) +
(proratedPropertyTaxes === '' ? 0 : Number(proratedPropertyTaxes)) +
(proratedHoaDues === '' ? 0 : Number(proratedHoaDues)) +
(sellerConcessions === '' ? 0 : Number(sellerConcessions)) +
(otherFees === '' ? 0 : Number(otherFees));
// Total closing costs
const calculatedTotalClosingCosts = agentCommission + otherFixedCosts;
setTotalClosingCosts(calculatedTotalClosingCosts);
// Calculate net proceeds
const calculatedNetProceeds = price - payoff - calculatedTotalClosingCosts;
setNetProceeds(calculatedNetProceeds);
if (calculatedNetProceeds < 0) {
setError('Your estimated net proceeds are negative. You might owe money at closing.');
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ my: 4, textAlign: 'center' }}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
Home Sale Closing Costs Calculator
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Estimate your net proceeds from a home sale by calculating closing costs.
</Typography>
</Box>
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
<Box
component="form"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
gap: 3,
mb: 3,
}}
noValidate
autoComplete="off"
>
{/* Primary Sale Details */}
<TextField
label="Home Sale Price ($)"
type="number"
value={salePrice}
onChange={(e) => setSalePrice(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Mortgage Payoff Amount ($)"
type="number"
value={mortgagePayoff}
onChange={(e) => setMortgagePayoff(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
{/* Commission and Other Fees */}
<TextField
label="Agent Commission Rate (%)"
type="number"
value={agentCommissionRate}
onChange={(e) =>
setAgentCommissionRate(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0, max: 100 }}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
/>
<TextField
label="Title Insurance (Owner's Policy) ($)"
type="number"
value={titleInsuranceOwner}
onChange={(e) =>
setTitleInsuranceOwner(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Escrow Fees ($)"
type="number"
value={escrowFees}
onChange={(e) => setEscrowFees(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Recording Fees ($)"
type="number"
value={recordingFees}
onChange={(e) => setRecordingFees(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Transfer Taxes / Deed Stamps ($)"
type="number"
value={transferTaxes}
onChange={(e) => setTransferTaxes(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Attorney Fees ($)"
type="number"
value={attorneyFees}
onChange={(e) => setAttorneyFees(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Prorated Property Taxes ($)"
type="number"
value={proratedPropertyTaxes}
onChange={(e) =>
setProratedPropertyTaxes(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Prorated HOA Dues ($)"
type="number"
value={proratedHoaDues}
onChange={(e) =>
setProratedHoaDues(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Seller Concessions ($)"
type="number"
value={sellerConcessions}
onChange={(e) =>
setSellerConcessions(e.target.value === '' ? '' : Number(e.target.value))
}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
<TextField
label="Other Fees ($)"
type="number"
value={otherFees}
onChange={(e) => setOtherFees(e.target.value === '' ? '' : Number(e.target.value))}
fullWidth
variant="outlined"
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
/>
</Box>
<Button
variant="contained"
color="primary"
onClick={calculateNetTerms}
fullWidth
size="large"
>
Calculate Net Proceeds
</Button>
{error && (
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
</Paper>
{totalClosingCosts > 0 || netProceeds !== 0 ? ( // Display results if calculations have been made
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
Sale Summary
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" color="primary">
Estimated Total Closing Costs: ${totalClosingCosts.toFixed(2)}
</Typography>
<Typography variant="h6" color={netProceeds >= 0 ? 'primary' : 'error'} sx={{ mt: 1 }}>
Estimated Net Proceeds: ${netProceeds.toFixed(2)}
</Typography>
<Typography variant="body2" sx={{ mt: 2 }}>
*This is an estimate. Actual closing costs and net proceeds may vary. Consult with
your real estate agent or attorney for precise figures.
</Typography>
</Box>
</Paper>
) : null}
</Container>
);
};
export default NetTermsSheet;

View File

@@ -0,0 +1,35 @@
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
import ProfessionalUpgrade from 'components/sections/dashboard/Home/Upgrade/ProfessionalUpgrade';
import PropertyOwnerUpgrade from 'components/sections/dashboard/Home/Upgrade/PropertyOwnerUpgrade';
import { AccountContext } from 'contexts/AccountContext';
import { useContext } from 'react';
import { UserAPI } from 'types';
export type UpgradeProps = {
account: UserAPI;
};
const UpgradePage: React.FC = () => {
const { account, accountLoading } = useContext(AccountContext);
if (accountLoading) {
return <DashboardLoading />;
}
if (!account) {
return <DashboardErrorPage />;
}
if (account.user_type === 'property_owner') {
return <PropertyOwnerUpgrade onUpgradeClick={() => {}} />;
} else if (account.user_type === 'vendor') {
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
} else if (account.user_type === 'attorney') {
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
} else if (account.user_type === 'real_estate_agent') {
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
//return (<VendorProfile account={account} />)
}
};
export default UpgradePage;

View File

@@ -1,26 +1,142 @@
import { ReactElement } from 'react';
import { ReactElement, useEffect, useState } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import Grid from '@mui/material/Unstable_Grid2';
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import { axiosInstance } from '../../axiosApi';
const Vendors = (): ReactElement => {
return(
<Grid
container
component="main"
columns={12}
spacing={3.75}
flexGrow={1}
pt={4.375}
pr={1.875}
pb={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
pl: { xs: 3.75, lg: 0 },
}}
>
<p>Vendors</p>
</Grid>
)
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
// Simulate fetching data
let fetchedVendors: VendorItem[] = [];
useEffect(() => {
const fetchData = async () => {
const response = await axiosInstance.get<VendorAPI[]>('/vendors/');
if (response.data.length > 0) {
fetchedVendors = response.data.map((item) => {
return {
id: item.user.id,
name: item.business_name,
categoryId: item.business_type,
phone: item.phone_number,
email: item.user.email,
address: item.address,
contactPerson: item.user.first_name + ' ' + item.user.last_name,
serviceAreas: item.service_areas,
vendorImageUrl: 'https://via.placeholder.com/60?text=SE',
rating: 4.5,
servicesOffered: ['Residential wiring', 'Commercial electrical', 'Panel upgrades'],
latitude: item.latitude,
longitude: item.longitude,
};
});
setAllVendors(fetchedVendors);
}
// Process categories based on fetched vendors
const categoryMap = new Map<
string,
{
name: string;
description: string;
imageUrl: string;
numVendors: number;
totalRating: number;
}
>();
const defaultCategoryImages: { [key: string]: string } = {
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician',
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber',
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping',
};
fetchedVendors.forEach((vendor) => {
const categoryId = vendor.categoryId;
if (!categoryMap.has(categoryId)) {
let categoryName = '';
switch (categoryId) {
case 'electrician':
categoryName = 'Electricians';
break;
case 'plumber':
categoryName = 'Plumbers';
break;
case 'landscaping':
categoryName = 'Landscaping';
break;
default:
categoryName = 'Other Service';
}
categoryMap.set(categoryId, {
name: categoryName,
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
imageUrl:
defaultCategoryImages[categoryId] ||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
numVendors: 0,
totalRating: 0,
});
}
const categoryData = categoryMap.get(categoryId)!;
categoryData.numVendors += 1;
categoryData.totalRating += vendor.rating;
});
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
([id, data]) => ({
id,
name: data.name,
description: data.description,
imageUrl: data.imageUrl,
numVendors: data.numVendors,
categoryRating: data.numVendors > 0 ? data.totalRating / data.numVendors : undefined,
}),
);
setVendorCategories(processedCategories);
};
fetchData();
}, []);
return (
<DashboardTemplate<VendorCategory, VendorItem>
pageTitle="Service Vendors"
data={{ categories: vendorCategories, items: allVendors }}
renderCategoryGrid={(categories, onSelectCategory) => (
<CategoryGridTemplate
categories={categories}
onSelectCategory={(id) => onSelectCategory(id)}
renderCategoryCard={(category, onSelect) => (
<VendorCategoryCard category={category as VendorCategory} onSelectCategory={onSelect} />
)}
/>
)}
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
<ItemListDetailTemplate
category={selectedCategory}
items={itemsInSelectedCategory}
onBack={onBack}
renderListItem={(item, isSelected, onSelect) => (
<VendorListItem
vendor={item as VendorItem}
isSelected={isSelected}
onSelect={() => onSelect(item.id)}
/>
)}
renderItemDetail={(item) => (
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
)}
/>
)}
/>
);
};
export default Vendors;

View File

@@ -1,5 +1,6 @@
import { ReactElement, Suspense, useState } from 'react';
import { ReactElement, Suspense, useContext, useState } from 'react';
import {
Alert,
Button,
FormControl,
IconButton,
@@ -15,12 +16,55 @@ import loginBanner from 'assets/authentication-banners/green.png';
import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image';
import{axiosInstance} from '../../axiosApi.js';
import { useNavigate } from 'react-router-dom';
import { Form, Formik } from 'formik';
import { AuthContext } from 'contexts/AuthContext.js';
type loginValues = {
email: string;
password: string;
}
const Login = (): ReactElement => {
const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const [errorMessage, setErrorMessage] = useState<any | null>(null);
const navigate = useNavigate();
const {setAuthentication} = useContext(AuthContext);
const handleLogin = async({email, password}: loginValues): Promise<void> => {
try{
const response = await axiosInstance.post('/token/',
{
email: email,
password: password
}
)
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
const get_user_response = await axiosInstance.get('/user/')
setAuthentication(true)
navigate("/")
}catch (error) {
const hasErrors = Object.keys(error.response.data).length > 0;
if (hasErrors) {
setErrorMessage(error.response.data)
}else{
setErrorMessage(null);
}
}
}
return (
<Stack
direction="row"
@@ -35,7 +79,39 @@ const Login = (): ReactElement => {
</Link>
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
<Typography variant="h3">Login</Typography>
<Formik
initialValues={{
email: '',
password: '',
}}
onSubmit={handleLogin}
>
{({setFieldValue}) => (
<Form>
<FormControl variant="standard" fullWidth>
{errorMessage ? (
<Alert severity='error'>
<ul>
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
<li key={fieldName}>
<strong>{fieldName}</strong>
{errorMessages.length > 0 ? (
<ul>
{errorMessages.map((message, index) => (
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
))}
</ul>
) : (
<span> No specific errors for this field.</span>
)}
</li>
))}
</ul>
</Alert>
): null}
<InputLabel shrink htmlFor="email">
Email
</InputLabel>
@@ -43,6 +119,7 @@ const Login = (): ReactElement => {
variant="filled"
placeholder="Enter your email"
id="email"
onChange={(event) => setFieldValue('email', event.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -59,6 +136,7 @@ const Login = (): ReactElement => {
<TextField
variant="filled"
placeholder="********"
onChange={(event) => setFieldValue('password', event.target.value)}
type={showPassword ? 'text' : 'password'}
id="password"
InputProps={{
@@ -93,9 +171,12 @@ const Login = (): ReactElement => {
Forget password
</Link>
</Typography>
<Button variant="contained" fullWidth>
<Button variant="contained" type={'submit'} fullWidth>
Log in
</Button>
</Form>
)}
</Formik>
<Typography variant="body2" color="text.secondary">
Don't have an account ?{' '}
<Link

View File

@@ -1,6 +1,7 @@
import { ReactElement, Suspense, useState } from 'react';
import { Form, Formik } from 'formik';
import { ErrorMessage, Form, Formik } from 'formik';
import {
Alert,
Button,
FormControl,
FormControlLabel,
@@ -21,6 +22,7 @@ import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image';
import { axiosInstance } from '../../axiosApi.js';
import { useNavigate } from 'react-router-dom';
type SignUpValues = {
email: string;
@@ -29,49 +31,54 @@ type SignUpValues = {
password: string;
password2: string;
ownerType: string;
}
};
const SignUp = (): ReactElement => {
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
// const [firstName, setFirstName] = useState('');
// const [lastName, setLastName] = useState('');
// const [email, setEmail] = useState('');
// const [password, setPassword] = useState('');
// const [password2, setPassword2] = useState('');
// const [ownerType, setOwnerType] = useState('');
const [errorMessage, setErrorMessage] = useState<any | null>(null);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const handleClickShowPassword2 = () => setShowPassword2(!showPassword2);
const navigate = useNavigate();
const handleSignUp = async({email, first_name, last_name, ownerType, password, password2}: SignUpValues): Promise<void> => {
console.log({
const handleSignUp = async ({
email,
first_name,
last_name,
ownerType,
password,
password2,
}: SignUpValues): Promise<void> => {
try {
const response = await axiosInstance.post('/register/', {
email: email,
first_name: first_name,
last_name: last_name,
user_type: ownerType,
password: password,
password2: password2
})
const response = await axiosInstance.post('/api/register',
{
email: email,
first_name: first_name,
last_name: last_name,
user_type: ownerType,
password: password,
password2: password2
password2: password2,
});
if (response.status == 201) {
navigate('/authentication/login');
} else {
console.log(`No good: ${response}`);
}
)
} catch (error) {
const hasErrors = Object.keys(error.response.data).length > 0;
if (hasErrors) {
setErrorMessage(error.response.data);
} else {
setErrorMessage(null);
}
}
};
return (
<Stack
direction="row"
bgcolor="background.paper"
boxShadow={(theme) => theme.shadows[3]}
width={{ md: 960 }}
>
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
@@ -87,20 +94,39 @@ const SignUp = (): ReactElement => {
last_name: '',
password: '',
password2: '',
ownerType: "property_owner"
ownerType: 'property_owner',
}}
onSubmit={handleSignUp}
>
{(formik) => (
{({ setFieldValue }) => (
<Form>
<FormControl variant="standard" fullWidth>
{errorMessage ? (
<Alert severity="error">
<ul>
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
<li key={fieldName}>
<strong>{fieldName}</strong>
{errorMessages.length > 0 ? (
<ul>
{errorMessages.map((message, index) => (
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
))}
</ul>
) : (
<span> No specific errors for this field.</span>
)}
</li>
))}
</ul>
</Alert>
) : null}
<InputLabel shrink htmlFor="name">
First Name
</InputLabel>
<TextField
variant="filled"
onChange={(event) => formik.setFieldValue('first_name', event.target.value)}
onChange={(event) => setFieldValue('first_name', event.target.value)}
placeholder="Enter your first name"
id="first_name"
InputProps={{
@@ -118,7 +144,7 @@ const SignUp = (): ReactElement => {
</InputLabel>
<TextField
variant="filled"
onChange={(event) => formik.setFieldValue('last_name', event.target.value)}
onChange={(event) => setFieldValue('last_name', event.target.value)}
placeholder="Enter your last name"
id="last_name"
InputProps={{
@@ -147,7 +173,7 @@ const SignUp = (): ReactElement => {
width: 1,
backgroundColor: 'action.focus',
}}
onChange={(event) => formik.setFieldValue('email', event.target.value)}
onChange={(event) => setFieldValue('email', event.target.value)}
/>
</FormControl>
<FormControl variant="standard" fullWidth>
@@ -156,13 +182,33 @@ const SignUp = (): ReactElement => {
</InputLabel>
<RadioGroup
row
onChange={(event) => formik.setFieldValue('ownerType', event.target.value)}
onChange={(event) => setFieldValue('ownerType', event.target.value)}
>
<FormControlLabel value="property_owner" control={<Radio />} name="ownerType" label="Owner"/>
<FormControlLabel value="vendor" control={<Radio />} name="ownerType" label="Vendor" />
<FormControlLabel
value="property_owner"
control={<Radio />}
name="ownerType"
label="Owner"
/>
<FormControlLabel
value="vendor"
control={<Radio />}
name="ownerType"
label="Vendor"
/>
<FormControlLabel
value="attorney"
control={<Radio />}
name="ownerType"
label="Attorney"
/>
<FormControlLabel
value="real_estate_agent"
control={<Radio />}
name="ownerType"
label="Real Estate Agent"
/>
</RadioGroup>
</FormControl>
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="password">
@@ -171,7 +217,7 @@ const SignUp = (): ReactElement => {
<TextField
variant="filled"
placeholder="********"
onChange={(event) => formik.setFieldValue('password', event.target.value)}
onChange={(event) => setFieldValue('password', event.target.value)}
type={showPassword ? 'text' : 'password'}
id="password"
InputProps={{
@@ -203,7 +249,7 @@ const SignUp = (): ReactElement => {
<TextField
variant="filled"
placeholder="********"
onChange={(event) => formik.setFieldValue('password2', event.target.value)}
onChange={(event) => setFieldValue('password2', event.target.value)}
type={showPassword2 ? 'text' : 'password'}
id="password"
InputProps={{
@@ -232,11 +278,9 @@ const SignUp = (): ReactElement => {
Sign up
</Button>
</Form>
)}
</Formik>
<Typography variant="body2" color="text.secondary">
Already have an account ?{' '}
<Link

View File

@@ -0,0 +1,40 @@
import AttorneyDashboard from 'components/sections/dashboard/Home/Dashboard/AttorneyDashboard';
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
import PropertyOwnerDashboard from 'components/sections/dashboard/Home/Dashboard/PropertyOwnerDashboard';
import RealEstateAgentDashboard from 'components/sections/dashboard/Home/Dashboard/RealEstateAgentDashboard';
import VendorDashboard from 'components/sections/dashboard/Home/Dashboard/VendorDashboard';
import { AccountContext } from 'contexts/AccountContext';
import { ReactElement, useContext } from 'react';
import { UserAPI } from 'types';
export type DashboardProps = {
account: UserAPI;
};
const Dashboard = (): ReactElement => {
const { account, accountLoading } = useContext(AccountContext);
if (accountLoading) {
return <DashboardLoading />;
}
if (!account) {
return <DashboardErrorPage />;
}
//change what to show based on the account type
if (account.user_type === 'property_owner') {
return <PropertyOwnerDashboard account={account} />;
} else if (account.user_type === 'vendor') {
return <VendorDashboard account={account} />;
} else if (account.user_type === 'attorney') {
return <AttorneyDashboard account={account} />;
} else if (account.user_type === 'real_estate_agent') {
return <RealEstateAgentDashboard account={account} />;
} else {
return <p>404 error</p>;
}
};
export default Dashboard;

View File

@@ -1,65 +0,0 @@
import Grid from '@mui/material/Unstable_Grid2';
import { Stack } from '@mui/material';
import { ReactElement } from 'react';
import TopSellingProduct from 'components/sections/dashboard/Home/Sales/TopSellingProduct/TopSellingProduct';
import WebsiteVisitors from 'components/sections/dashboard/Home/Sales/WebsiteVisitors/WebsiteVisitors';
import SaleInfoCards from 'components/sections/dashboard/Home/Sales/SaleInfoSection/SaleInfoCards';
import BuyersProfile from 'components/sections/dashboard/Home/Sales/BuyersProfile/BuyersProfile';
import NewCustomers from 'components/sections/dashboard/Home/Sales/NewCustomers/NewCustomers';
import Revenue from 'components/sections/dashboard/Home/Sales/Revenue/Revenue';
import { drawerWidth } from 'layouts/main-layout';
import {EducationInfoCards} from 'components/sections/dashboard/Home/Education/EducationInfo';
import { ProperyInfoCards } from 'components/sections/dashboard/Home/Property/PropertyInfo';
const Sales = (): ReactElement => {
return (
<Grid
container
component="main"
columns={12}
spacing={3.75}
flexGrow={1}
pt={4.375}
pr={1.875}
pb={0}
sx={{
width: { md: `calc(100% - ${drawerWidth}px)` },
pl: { xs: 3.75, lg: 0 },
}}
>
<Grid xs={12} md={4}>
<ProperyInfoCards />
</Grid>
<Grid xs={12} md={8}>
<EducationInfoCards />
</Grid>
<Grid xs={12}>
<SaleInfoCards />
</Grid>
<Grid xs={12} md={8}>
<Revenue />
</Grid>
<Grid xs={12} md={4}>
<WebsiteVisitors />
</Grid>
<Grid xs={12} lg={8}>
<TopSellingProduct />
</Grid>
<Grid xs={12} lg={4}>
<Stack
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
gap={3.75}
height={1}
width={1}
>
<NewCustomers />
<BuyersProfile />
</Stack>
</Grid>
</Grid>
);
};
export default Sales;

View File

@@ -1,4 +1,4 @@
import { ReactElement, Suspense, useState } from 'react';
import { ReactElement, Suspense, useContext, useState } from 'react';
import {
Button,
FormControl,
@@ -11,119 +11,220 @@ import {
TextField,
Typography,
} from '@mui/material';
import loginBanner from 'assets/authentication-banners/green.png';
import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image';
const Login = (): ReactElement => {
const [showPassword, setShowPassword] = useState(false);
import { useNavigate } from 'react-router-dom';
import { axiosInstance } from '../../axiosApi';
import { AccountContext } from 'contexts/AccountContext';
import { AxiosResponse } from 'axios';
import { UserAPI } from 'types';
const handleClickShowPassword = () => setShowPassword(!showPassword);
const TermsOfService = (): ReactElement => {
const [errors, setErrors] = useState<string | null>(null);
const navigate = useNavigate();
const { account, setAccount, accountLoading } = useContext(AccountContext);
const handleSignTOS = async (): Promise<void> => {
console.log('handle tos signing');
try {
const { data }: AxiosResponse<UserAPI> = await axiosInstance.put('/user/acknowledge_tos/');
if (data) {
setAccount(data);
}
navigate('/');
} catch (error) {
setErrors(error);
}
};
return (
<Stack
direction="row"
bgcolor="background.paper"
boxShadow={(theme) => theme.shadows[3]}
height={560}
minHeight={560}
width={{ md: 960 }}
>
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
<Link href="/" height="fit-content">
<Image src={logo} width={82.6} />
</Link>
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
<Typography variant="h3">Login</Typography>
<Stack m={2.5} gap={10}>
<Stack alignItems="center" gap={2.5} mx="auto">
<Typography variant="h1">Terms Of Service</Typography>
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="email">
Email
</InputLabel>
<TextField
variant="filled"
placeholder="Enter your email"
id="email"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconifyIcon icon="ic:baseline-email" />
</InputAdornment>
),
}}
/>
</FormControl>
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="password">
Password
</InputLabel>
<TextField
variant="filled"
placeholder="********"
type={showPassword ? 'text' : 'password'}
id="password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
sx={{
color: 'text.secondary',
}}
>
{showPassword ? (
<IconifyIcon icon="ic:baseline-key-off" />
) : (
<IconifyIcon icon="ic:baseline-key" />
)}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography variant="caption">Last Updated: July 15, 2025</Typography>
<Typography variant="body1">
Welcome to [Your Website Name]! These Terms of Service ("Terms") govern your access to
and use of the [Your Website Name] website portal (the "Service"), operated by [Your
Company Name] ("we," "us," or "our").
</Typography>
<Typography variant="body1">
By accessing or using the Service, you agree to be bound by these Terms and our
Privacy Policy. If you do not agree to these Terms, please do not use the Service.
</Typography>
<Typography>
<Typography variant="h4">1. Acceptance of Terms</Typography>
<Typography variant="body1">
By creating an account, accessing, or using the Service, you signify your agreement
to these Terms. You must be at least 18 years old or the age of legal majority in
your jurisdiction to use the Service. If you are accessing or using the Service on
behalf of a company or other legal entity, you represent that you have the authority
to bind such entity to these Terms.
</Typography>
<Typography variant="h4">2. Changes to Terms</Typography>
<Typography variant="body1">
We reserve the right, at our sole discretion, to modify or replace these Terms at
any time. If a revision is material, we will provide at least 30 days' notice prior
to any new terms taking effect. What constitutes a material change will be
determined at our sole discretion. By continuing to access or use our Service after
those revisions become effective, you agree to be bound by the revised terms.
</Typography>
<Typography variant="h4">3. Privacy Policy</Typography>
<Typography variant="body1">
Your use of the Service is also governed by our Privacy Policy, which explains how
we collect, use, and disclose information about you. Please review our Privacy
Policy at [Link to your Privacy Policy] to understand our practices.
</Typography>
<Typography variant="h4">4. User Accounts</Typography>
<Typography variant="body1">
Account Creation: To access certain features of the Service, you may be required to
register for an account. You agree to provide accurate, current, and complete
information during the registration process and to update such information to keep
it accurate, current, and complete.
</Typography>
<Typography variant="body1">
Account Security: You are responsible for safeguarding the password that you use to
access the Service and for any activities or actions under your password. You agree
not to disclose your password to any third party. You must notify us immediately
upon becoming aware of any breach of security or unauthorized use of your account.
</Typography>
<Typography variant="body1">
Account Termination: We may suspend or terminate your account and access to the
Service immediately, without prior notice or liability, for any reason whatsoever,
including without limitation if you breach the Terms.
</Typography>
<Typography variant="h4">5. User Conduct</Typography>
<Typography variant="body1">
You agree to use the Service only for lawful purposes and in a way that does not
infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the
Service. Prohibited behavior includes harassing or causing distress or inconvenience
to any other user, transmitting obscene or offensive content, or disrupting the
normal flow of dialogue within the Service.
</Typography>
<Typography variant="body1">
You agree not to:
<ul>
<li>
Use the Service in any way that violates any applicable national or
international law or regulation.
</li>
<li>
Engage in any conduct that restricts or inhibits anyone's use or enjoyment of
the Service, or which, as determined by us, may harm us or users of the Service
or expose them to liability.
</li>
<li>
Use the Service to transmit any unsolicited or unauthorized advertising or
promotional material or any other form of similar solicitation (spam).
</li>
<li>
Impersonate or attempt to impersonate [Your Company Name], a [Your Company Name]
employee, another user, or any other person or entity.
</li>
<li>
Introduce any viruses, Trojan horses, worms, logic bombs, or other material that
is malicious or technologically harmful.
</li>
</ul>
</Typography>
<Typography variant="h4">6. Intellectual Property</Typography>
The Service and its original content (excluding content provided by users), features,
and functionality are and will remain the exclusive property of [Your Company Name]
and its licensors. The Service is protected by copyright, trademark, and other laws of
both the [Your Country] and foreign countries. Our trademarks and trade dress may not
be used in connection with any product or service without the prior written consent of
[Your Company Name].
<Typography variant="h4">7. User-Generated Content</Typography>
If the Service allows you to post, link, store, share, and otherwise make available
certain information, text, graphics, videos, or other material ("Content"), you are
responsible for the Content that you post on or through the Service, including its
legality, reliability, and appropriateness. By posting Content to the Service, you
grant us the right and license to use, modify, publicly perform, publicly display,
reproduce, and distribute such Content on and through the Service. You retain any and
all of your rights to any Content you submit, post or display on or through the
Service and you are responsible for protecting those rights. You represent and warrant
that: (i) the Content is yours (you own it) or you have the right to use it and grant
us the rights and license as provided in these Terms, and (ii) the posting of your
Content on or through the Service does not violate the privacy rights, publicity
rights, copyrights, contract rights or any other rights of any person.
<Typography variant="h4">8. Links to Other Websites</Typography>
Our Service may contain links to third-party web sites or services that are not owned
or controlled by [Your Company Name]. [Your Company Name] has no control over, and
assumes no responsibility for, the content, privacy policies, or practices of any
third-party web sites or services. You further acknowledge and agree that [Your
Company Name] shall not be responsible or liable, directly or indirectly, for any
damage or loss caused or alleged to be caused by or in connection with use of or
reliance on any such content, goods or services available on or through any such web
sites or services. We strongly advise you to read the terms and conditions and privacy
policies of any third-party web sites or services that you visit.
<Typography variant="h4">9. Disclaimer of Warranties</Typography>
Your use of the Service is at your sole risk. The Service is provided on an "AS IS"
and "AS AVAILABLE" basis. The Service is provided without warranties of any kind,
whether express or implied, including, but not limited to, implied warranties of
merchantability, fitness for a particular purpose, non-infringement or course of
performance. [Your Company Name] its subsidiaries, affiliates, and its licensors do
not warrant that a) the Service will function uninterrupted, secure or available at
any particular time or location; b) any errors or defects will be corrected; c) the
Service is free of viruses or other harmful components; or d) the results of using the
Service will meet your requirements.
<Typography variant="h4">10. Limitation of Liability</Typography>
In no event shall [Your Company Name], nor its directors, employees, partners, agents,
suppliers, or affiliates, be liable for any indirect, incidental, special,
consequential or punitive damages, including without limitation, loss of profits,
data, use, goodwill, or other intangible losses, resulting from (i) your access to or
use of or inability to access or use the Service; (ii) any conduct or content of any
third party on the Service; (iii) any content obtained from the Service; and (iv)
unauthorized access, use or alteration of your transmissions or content, whether based
on warranty, contract, tort (including negligence) or any other legal theory, whether
or not we have been informed of the possibility of such damage, and even if a remedy
set forth herein is found to have failed of its essential purpose.
<Typography variant="h4">11. Indemnification</Typography>
You agree to defend, indemnify and hold harmless [Your Company Name] and its licensee
and licensors, and their employees, contractors, agents, officers and directors, from
and against any and all claims, damages, obligations, losses, liabilities, costs or
debt, and expenses (including but not limited to attorney's fees), resulting from or
arising out of a) your use and access of the Service, by you or any person using your
account and password; b) a breach of these Terms; or c) Content posted on the Service.
<Typography variant="h4">12. Governing Law</Typography>
These Terms shall be governed and construed in accordance with the laws of [Your
State/Country], without regard to its conflict of law provisions. Our failure to
enforce any right or provision of these Terms will not be considered a waiver of those
rights. If any provision of these Terms is held to be invalid or unenforceable by a
court, the remaining provisions of these Terms will remain in effect. These Terms
constitute the entire agreement between us regarding our Service, and supersede and
replace any prior agreements we might have between us regarding the Service.
<Typography variant="h4">13. Contact Us</Typography>
If you have any questions about these Terms, please contact us: By email: [Your Email
Address] By visiting this page on our website: [Link to your Contact Us page]
</Typography>
</FormControl>
<Typography
variant="body1"
sx={{
alignSelf: 'flex-end',
}}
>
<Link href="/authentication/forgot-password" underline="hover">
Forget password
</Link>
</Typography>
<Button variant="contained" fullWidth>
Log in
></Typography>
<Button variant="contained" fullWidth onClick={handleSignTOS}>
Acknowledge
</Button>
<Typography variant="body2" color="text.secondary">
Don't have an account ?{' '}
<Link
href="/authentication/sign-up"
underline="hover"
fontSize={(theme) => theme.typography.body1.fontSize}
>
Sign up
</Link>
</Typography>
</Stack>
</Stack>
<Suspense
fallback={
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
}
>
<Image
alt="Login banner"
src={loginBanner}
sx={{
width: 0.5,
display: { xs: 'none', md: 'block' },
}}
/>
</Suspense>
></Suspense>
</Stack>
);
};
export default Login;
export default TermsOfService;

View File

@@ -6,12 +6,18 @@ export const rootPaths = {
authRoot: 'authentication',
notificationsRoot: 'notifications',
calendarRoot: 'calendar',
messageRoot: 'messages',
messageRoot: 'conversations',
errorRoot: 'error',
educationRoot: 'education',
propertyRoot: 'property',
vendorsRoot: 'vendors',
termsOfServiceRoot: 'terms-of-service',
toolsRoot: 'tools',
profileRoot: 'profile',
offersRoot: 'offers',
bidsRoot: 'bids',
vendorBidsRoot: 'vendor-bids',
upgradeRoot: 'upgrade',
};
export default {
@@ -24,7 +30,20 @@ export default {
education: `/${rootPaths.educationRoot}`,
educationLesson: `/${rootPaths.educationRoot}/lesson`,
property: `/${rootPaths.propertyRoot}`,
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
propertySearch: `/${rootPaths.propertyRoot}/search`,
vendors: `/${rootPaths.vendorsRoot}`,
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
};
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
amoritizationTable: `/${rootPaths.toolsRoot}/amoritization-table`,
homeAffordability: `/${rootPaths.toolsRoot}/home-affordability`,
netTermsSheet: `/${rootPaths.toolsRoot}/net-terms-sheet`,
messages: `/${rootPaths.messageRoot}/`,
offers: `/${rootPaths.offersRoot}/`,
bids: `/${rootPaths.bidsRoot}/`,
vendorBids: `/${rootPaths.vendorBidsRoot}/`,
upgrade: `/${rootPaths.upgradeRoot}/`,
// need to do these pages
profile: `/${rootPaths.profileRoot}/`,
};

View File

@@ -1,5 +1,12 @@
import { ReactNode, Suspense, lazy, useContext } from 'react';
import { Navigate, Outlet, RouteObject, RouterProvider, createBrowserRouter } from 'react-router-dom';
import {
Navigate,
Outlet,
RouteObject,
RouterProvider,
createBrowserRouter,
useNavigate,
} from 'react-router-dom';
import paths, { rootPaths } from './paths';
@@ -11,6 +18,20 @@ import Vendors from 'pages/Vendors/Vendors';
import EducationDetail from 'components/sections/dashboard/Home/Education/EducationDetail';
import TermsOfService from 'pages/home/TermsOfService';
import { AuthContext, AuthProvider } from 'contexts/AuthContext';
import AmoritizationTable from 'pages/Tools/AmoritizationTable';
import MortgageCalculator from 'pages/Tools/MortgageCalculator';
import HomeAffordability from 'pages/Tools/HomeAffordability';
import Messages from 'pages/Messages/Messages';
import Offers from 'pages/Offers/Offers';
import NetTermsSheet from 'pages/Tools/NetTermsSheet';
import { AccountContext } from 'contexts/AccountContext';
import ProfilePage from 'pages/Profile/Profile';
import Dashboard from 'pages/home/Dashboard';
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
import PropertySearchPage from 'pages/Property/PropertySearchPage';
import BidsPage from 'pages/Bids/Bids';
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
import UpgradePage from 'pages/Upgrade/UpgradePage';
const App = lazy(() => import('App'));
const MainLayout = lazy(async () => {
@@ -31,13 +52,6 @@ const Error404 = lazy(async () => {
return import('pages/errors/Error404');
});
const Sales = lazy(async () => {
return Promise.all([
import('pages/home/Sales'),
new Promise((resolve) => setTimeout(resolve, 500)),
]).then(([moduleExports]) => moduleExports);
});
const Login = lazy(async () => import('pages/authentication/Login'));
const SignUp = lazy(async () => import('pages/authentication/SignUp'));
@@ -46,13 +60,24 @@ const ForgotPassword = lazy(async () => import('pages/authentication/ForgotPassw
type ProtectedRouteProps = {
children?: ReactNode;
}
};
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const navigate = useNavigate();
const { authenticated, loading } = useContext(AuthContext);
const { account, accountLoading } = useContext(AccountContext);
if (!authenticated && !loading) {
if (!loading) {
if (!authenticated) {
return <Navigate to="/authentication/login" replace />;
} else if (!accountLoading && account) {
console.log(account);
if (!account.tos_signed) {
console.log('go to tos');
//navigate('/terms-of-service');
return <Navigate to="/terms-of-service" replace />;
}
}
}
return children;
@@ -80,7 +105,8 @@ const routes: RouteObject[] = [
children: [
{
path: paths.home,
element: <Sales />,
element: <Dashboard />,
//element: <Sales />,
},
],
},
@@ -145,6 +171,30 @@ const routes: RouteObject[] = [
path: paths.property,
element: <Property />,
},
{
path: paths.propertyDetail,
element: <PropertyDetailPage />,
},
{
path: paths.propertySearch,
element: <PropertySearchPage />,
},
],
},
{
path: rootPaths.termsOfServiceRoot,
element: (
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
),
children: [
{
path: paths.termsOfService,
element: <TermsOfService />,
},
],
},
{
@@ -189,6 +239,151 @@ const routes: RouteObject[] = [
},
],
},
{
path: rootPaths.offersRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.offers,
element: <Offers />,
},
],
},
{
path: rootPaths.bidsRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.bids,
element: <BidsPage />,
},
],
},
{
path: rootPaths.vendorBidsRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.vendorBids,
element: <VendorBidsPage />,
},
],
},
{
path: rootPaths.messageRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.messages,
element: <Messages />,
},
],
},
{
path: rootPaths.profileRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.profile,
element: <ProfilePage />,
},
],
},
{
path: rootPaths.upgradeRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.upgrade,
element: <UpgradePage />,
},
],
},
{
path: rootPaths.toolsRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.homeAffordability,
element: <HomeAffordability />,
},
{
path: paths.mortageCalculator,
element: <MortgageCalculator />,
},
{
path: paths.amoritizationTable,
element: <AmoritizationTable />,
},
{
path: paths.netTermsSheet,
element: <NetTermsSheet />,
},
],
},
{
path: '*',
element: <Error404 />,
@@ -197,7 +392,6 @@ const routes: RouteObject[] = [
},
];
const router = createBrowserRouter(routes, { basename: '/elegent' });
const router = createBrowserRouter(routes, { basename: '' });
export default router;

View File

@@ -0,0 +1,714 @@
// src/templates/types.ts
export interface NavItem {
title: string;
path: string;
icon?: string;
active: boolean;
collapsible: boolean;
sublist?: NavItem[];
}
// A generic "Category" type
export interface GenericCategory {
id: string; // Unique identifier for the category
name: string;
description: string;
imageUrl?: string; // Optional image for the category card
// Add any other common category properties here
}
// A generic "Item" type (e.g., a Video, a Vendor)
export interface GenericItem {
id: number; // Unique identifier for the item
name: string; // Corresponds to title for video, name for vendor
description: string; // Corresponds to description for video, short description for vendor
// Add any other common item properties here
}
// Data structure for the main template
export interface DashboardData<TCategory extends GenericCategory, TItem extends GenericItem> {
categories: TCategory[];
items: TItem[]; // All items, will be filtered by category within the template
}
export interface VideoCategory extends GenericCategory {
totalVideos: number;
completedVideos: number;
categoryProgress: number; // Calculated
}
export interface VideoItem extends GenericItem {
videoUrl: string;
progress_id: number;
status: 'completed' | 'in-progress' | 'not-started';
progress: number; // 0-100 for individual video progress
category: string; // Link back to category
categoryId: string; // Link back to category
duration: number;
}
// src/pages/VendorDashboardPage.tsx
// Specific interfaces for Vendor App
export interface VendorCategory extends GenericCategory {
numVendors: number; // Example specific stat
categoryRating?: number; // Example specific stat
}
export interface VendorItem extends GenericItem {
contactPerson: string;
phone: string;
email: string;
address: string;
vendorImageUrl: string;
rating: number;
servicesOffered: string[];
serviceAreas: string[];
categoryId: string; // Link back to category
latitude: number;
longitude: number;
}
export interface MessageItem extends GenericItem {
message: string;
}
export interface ConversationItem extends GenericItem {
messages: MessageItem[];
}
// API Types
export interface MessagesAPI {
id: number;
conversation: number;
sender: number;
text: string;
timestamp: string;
read: boolean;
}
export interface UserAPI {
id: number;
email: string;
first_name: string;
last_name: string;
user_type: 'property_owner' | 'vendor' | 'attorney' | 'real_estate_agent';
is_active: boolean;
date_joined: string;
tos_signed: boolean;
profile_created: boolean;
tier: 'basic' | 'premium';
}
export interface PropertyOwnerAPI {
user: UserAPI;
phone_number: string;
}
export interface VendorAPI {
user: UserAPI;
business_name: string;
business_type: 'electrician' | 'carpenter' | 'plumber' | 'inspector' | 'lendor' | 'other';
phone_number: string;
address: string;
city: string;
state: string;
zip_code: string;
// New fields for vendor profile
description: string; // A brief description of the vendor's business
website?: string; // Optional: Vendor's website URL
services: string[]; // List of services offered (e.g., ['Residential Wiring', 'Commercial Lighting'])
service_areas: string[]; // Areas where the vendor provides service (e.g., ['Chicago', 'Naperville'])
certifications?: string[]; // Optional: List of certifications or licenses
average_rating?: number; // Optional: Average rating from reviews
num_reviews?: number; // Optional: Number of reviews
profile_picture?: string; // Optional: URL to a profile picture/logo
latitude: number; // For Google Map
longitude: number; // For Google Map
views: number;
}
export interface AttorneyAPI {
user: UserAPI;
firm_name: string;
bar_number: string; // Unique identifier for attorneys
phone_number: string;
address: string;
city: string;
state: string;
zip_code: string;
specialties: string[]; // e.g., ['Real Estate Law', 'Contract Law', 'Litigation']
years_experience: number;
website?: string;
profile_picture?: string;
bio?: string; // Short biography
licensed_states: string[]; // States where the attorney is licensed
latitude: number; // For Google Map
longitude: number; // For Google Map
}
export interface RealEstateAgentAPI {
user: UserAPI;
brokerage_name: string;
license_number: string; // Real estate license number
phone_number: string;
address: string;
city: string;
state: string;
zip_code: string;
specialties: string[]; // e.g., ['Residential Sales', 'Commercial Leasing', 'First-Time Buyers']
years_experience: number;
website?: string;
profile_picture?: string;
bio?: string;
licensed_states: string[]; // States where the agent is licensed
agent_type: 'buyer_agent' | 'seller_agent' | 'dual_agent' | 'other';
latitude: number; // For Google Map
longitude: number; // For Google Map
}
export interface ConverationAPI {
id: number;
created_at: string;
updated_at: string;
proptery: number;
messages: MessagesAPI[];
vendor: VendorAPI;
property_owner: PropertyOwnerAPI;
}
export interface VideoCategoryAPI {
id: number;
name: string;
description: string;
}
export interface VideoAPI {
id: number;
category: VideoCategoryAPI;
title: string;
description: string;
link: string;
duration: number;
created_at: string;
updated_at: string;
}
export interface VideoProgressAPI {
id: number;
video: VideoAPI;
progress: number;
status: string;
last_watched: string;
user: number;
}
export interface PictureAPI {
id: number;
image: string;
}
export interface SaleHistoryAPI {
seq_no: number;
sale_date: string;
sale_amount: number;
}
export interface TaxHistoryAPI {
assessed_value: number;
assessment_year: number;
tax_amount: number;
year: number;
}
export interface OpenHouseAPI {
lsited_date: string;
}
export interface SchoolAPI {
id?: number;
address: string;
city: string;
state: string;
zip_code: string;
created_at?: string;
last_updated?: string;
latitude: number; // For Google Map
longitude: number; // For Google Map
school_type: 'public' | 'other';
enrollment: number;
grades: string;
name: string;
parent_rating: number;
rating: number;
}
export interface WalkScoreAPI {
walk_score: number;
walk_description: string;
created_at: string;
updated_at: string;
ws_link: string;
logo_url: string;
transit_score: number;
transit_description: string;
transit_summary: string;
bike_score: number;
bike_description: string;
}
export interface PropertiesAPI {
id: number;
owner: PropertyOwnerAPI;
address: string; // full address
street: string;
city: string;
state: string;
zip_code: string;
market_value: string;
loan_amount: string;
loan_term: number;
loan_start_date: string;
created_at: string;
last_updated: string;
//
pictures: PictureAPI[]; // Array of image URLs
description: string;
sq_ft: number;
features: string[]; // e.g., ['Pool', 'Garage', 'Garden']
num_bedrooms: number;
num_bathrooms: number;
latitude?: number; // For Google Map
longitude?: number; // For Google Map
realestate_api_id: number;
// New Fields for Status Card
property_status: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
listed_price?: string;
views: number;
saves: number;
listed_date?: string;
// New Fields for other cards
walk_score?: WalkScoreAPI;
sale_info?: SaleHistoryAPI[];
tax_info: TaxHistoryAPI;
open_houses?: OpenHouseAPI[];
schools: SchoolAPI[];
}
export interface BidImageAPI {
id: number;
image: string;
}
export interface BidResponseAPI {
id: number;
bid: number;
vendor: VendorAPI;
description: string;
price: string;
status: 'draft' | 'submitted' | 'selected';
created_at: string;
updated_at: string;
}
export interface BidAPI {
id: number;
property: number;
description: string;
bid_type: 'electrical' | 'plumbing' | 'carpentry' | 'general_contractor';
location: 'living_room' | 'basement' | 'kitchen' | 'bathroom' | 'bedroom' | 'outside';
images: BidImageAPI[];
responses: BidResponseAPI[];
created_at: string;
updated_at: string;
}
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
owner: number;
}
export interface OfferAPI {
id: number;
user: UserAPI;
property: PropertiesAPI;
previous_offer: OfferAPI;
status: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'countered';
updated_at: string;
created_at: string;
is_active: boolean;
}
// REAL ESTATE API Type Definitions
export interface AutocompleteResponseAPI {
input: {
search: string;
};
data: AutocompleteDataResponseAPI[];
totalResults: number;
returnedResults: number;
statusCode: number;
statusMessage: string;
live: boolean;
requestExecutionTimeMS: string;
}
export interface AutocompleteDataResponseAPI {
zip: string;
address: string;
city: string;
searchType: string;
stateId: string;
latitude: number;
county: string;
fips: string;
title: string;
house: string;
unit?: string;
countyId: string;
street: string;
location: string;
id: string;
state: string;
apn: string;
longitude: number;
}
export interface PropertyResponseDataMortgageAPI {
amount: number;
assumable: boolean;
book?: string;
page?: string;
documentNumber: string;
deedType: string;
documentDate: string;
granteeName: string;
interestRate: number;
interestRateType?: string;
lenderCode: string;
lenderName: string;
lenderType: string;
loanType: string;
loanTypeCode: string;
maturityDate: string;
mortgageId: string;
position: string;
recordingDate: string;
seqNo: number;
term: number;
termType: string;
transactionType?: string;
}
export interface PropertyResponseDataDemographicsAPI {
fmrEfficiency: string;
fmrFourBedroom: string;
fmrOneBedroom: string;
fmrThreeBedroom: string;
fmrTwoBedroom: string;
fmrYear: string;
hudAreaCode: string;
hudAreaName: string;
medianIncome: string;
suggestedRent: string;
}
export interface PropertyResponseDataSaleAPI {
book?: string;
page?: string;
documentNumber: string;
armsLength: boolean;
buyerNames: string;
documentType: string;
documentTypeCode: string;
downPayment: number;
ltv: number;
ownerIndividual: boolean;
priorOwnerIndividual: boolean;
priorOwnerMonthsOwned: number;
purchaseMethod: string;
recordingDate: string;
saleAmount: number;
saleDate: string;
sellerNames: string;
seqNo: number;
transactionType: string;
}
export interface PropertyResponseDataLotInfoAPI {}
export interface PropertyResponseDataMortgageHistoryAPI {}
export interface PropertyResponseDataOnwerInfoAPI {
mailAddress: {
address: string;
};
}
export interface PropertyResponseDataPropertyInfoAPI {
address: {
address: string;
carrierRoute: string;
city: string;
congressionalDistrict: string;
county: string;
fips: string;
house: string;
jurisdiction: string;
label: string;
preDirection?: string;
state: string;
street: string;
streetType: string;
unit: string;
unitType?: string;
zip: string;
zip4: string;
};
airConditioningType?: string;
attic: boolean;
basementFinishedPercent?: number;
basementSquareFeet: number;
basementSquareFeetFinished: number;
basementSquareFeetUnfinished: number;
basementType: string;
bathrooms: number;
bedrooms: number;
breezeway: boolean;
buildingSquareFeet: number;
buildingsCount: number;
carport: boolean;
construction?: string;
deck: boolean;
deckArea: number;
featureBalcony: boolean;
fireplace: boolean;
fireplaces?: string;
garageSquareFeet: number;
garageType: string;
heatingFuelType: string;
heatingType: string;
hoa: boolean;
interiorStructure?: string;
latitude: number;
livingSquareFeet: number;
longitude: number;
lotSquareFeet: number;
parcelAccountNumber?: number;
parkingSpaces: number;
partialBathrooms: number;
patio: boolean;
patioArea: string;
plumbingFixturesCount: number;
pool: boolean;
poolArea: number;
porchArea: number;
porchType: string;
pricePerSquareFoot: number;
propertyUse: string;
propertyUseCode: number;
roofConstruction?: string;
roofMaterial?: number;
roomsCount: number;
rvParking: boolean;
safetyFireSprinklers: boolean;
stories: number;
taxExemptionHomeownerFlag: boolean;
unitsCount: number;
utilitiesSewageUsage?: string;
utilitiesWaterSource?: string;
yearBuilt: number;
}
export interface PropertyResponseDataTaxInfoAPI {
assessedImprovementValue: number;
assessedLandValue: number;
assessedValue: number;
assessmentYear: number;
estimatedValue?: number;
marketImprovementValue: number;
marketLandValue: number;
marketValue: number;
propertyId: number;
taxAmount: string;
taxDelinquentYear?: number;
year: number;
}
export interface PropertyResponseDataCompsAPI {
id: string;
vacant: boolean;
absenteeOwner: boolean;
corporateOwned: boolean;
outOfStateAbsenteeOwner: boolean;
inStateAbsenteeOwner: boolean;
propertyId: string;
bedrooms: number;
bathrooms: number;
yearBuilt: string;
squareFeet: string;
estimatedValue: string;
equityPercent: string;
lastSaleDate: string;
lastSaleAmount: string;
mlsListingDate: string;
mlsLastStatusDate: string;
mlsLastSaleDate: string;
mlsDaysOnMarket: string;
mlsSoldPrice: number;
lotSquareFeet: string;
latitude: number;
longitude: number;
openMortgageBalance: string;
landUse: string;
propertyType: string;
propertyUse: string;
propertyUseCode: string;
owner1FirstName: string;
owner1LastName: string;
owner2FirstName: string;
owner2LastName: string;
preForeclosure: boolean;
cashBuyer: boolean;
privateLender: boolean;
lenderName: string;
address: {
zip: string;
city: string;
county: string;
state: string;
street: string;
address: string;
};
mailAddress: {
zip: string;
city: string;
county: string;
state: string;
street: string;
address: string;
};
}
export interface PropertyResponseDataSchoolAPI {
city: string;
enrollment: number;
grades: string;
location: string;
name: string;
parentRating: number;
rating: number;
address: string;
state: string;
zip: string;
type: 'Public' | 'other';
}
export interface PropertResponseDataAPI {
id: number;
MFH2to4: boolean;
MFH5plus: boolean;
absenteeOwner: boolean;
adjustableRate: boolean;
assumable: boolean;
auction: boolean;
equity: number;
bankOwned?: boolean;
cashBuyer: boolean;
cashSale: boolean;
corporateOwned: boolean;
death: boolean;
deathTransfer: boolean;
deedInLieu: boolean;
equityPercent: number;
estimatedEquity: number;
estimatedMortgageBalance: string;
estimatedMortgagePayment: string;
estimatedValue: number;
floodZone: boolean;
floodZoneDescription: string;
floodZoneType: string;
freeClear: boolean;
highEquity: boolean;
inStateAbsenteeOwner: boolean;
inherited: boolean;
investorBuyer: boolean;
judgment: boolean;
lastSaleDate: string;
lastSalePrice: string;
lastUpdateDate: string;
lien: boolean;
loanTypeCodeFirst: string;
loanTypeCodeSecond?: string;
loanTypeCodeThird?: string;
maturityDateFirst: string;
mlsActive: boolean;
mlsCancelled: boolean;
mlsDaysOnMarket?: number;
mlsFailed: boolean;
mlsFailedDate?: string;
mlsHasPhotos: boolean;
mlsLastSaleDate?: string;
mlsLastStatusDate?: string;
mlsListingDate?: string;
mlsListingPrice?: number;
mlsListingPricePerSquareFoot?: number;
mlsPending: boolean;
mlsSold: boolean;
mlsSoldPrice?: number;
mlsStatus?: string;
mlsTotalUpdates?: number;
mlsType?: string;
mobileHome: boolean;
noticeType?: string;
openMortgageBalance: number;
outOfStateAbsenteeOwner: boolean;
ownerOccupied: true;
preForeclosure: boolean;
privateLender: boolean;
propertyType: string;
quitClaim: boolean;
reapi_loaded_at?: string;
sheriffsDeed: boolean;
spousalDeath: boolean;
taxLien: boolean;
trusteeSale: boolean;
vacant: boolean;
warrantyDeed: boolean;
auctionInfo: {};
currentMortgages: PropertyResponseDataMortgageAPI[];
demographics: PropertyResponseDataDemographicsAPI;
//foreclosureInfo:
lastSale: PropertyResponseDataSaleAPI;
//linkedProperties
lotInfo: PropertyResponseDataLotInfoAPI;
//mlsHistory
//mlsKeywords
mortgageHistory: PropertyResponseDataMortgageHistoryAPI[];
//neighborhood
ownerInfo: PropertyResponseDataOnwerInfoAPI;
propertyInfo: PropertyResponseDataPropertyInfoAPI;
saleHistory: PropertyResponseDataSaleAPI[];
schools: PropertyResponseDataSchoolAPI[];
taxInfo: PropertyResponseDataTaxInfoAPI;
comps: PropertyResponseDataCompsAPI[];
}
export interface PropertyResponseAPI {
input: {
comps: boolean;
id: number;
exact_match: boolean;
};
data: PropertResponseDataAPI;
statusCode: number;
statusMessage: string;
live: boolean;
requestExecutionTimeMS: string;
propertyLookupExecutionTimeMS: string;
compsLookupExecutionTimeMS: string;
}
// Walk Score API Type Definitions

View File

@@ -0,0 +1,26 @@
export const formatTimestamp = (isoString: string) => {
const date = new Date(isoString);
return (
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +
' ' +
date.toLocaleDateString([], { month: 'short', day: 'numeric' })
);
};
export function extractLatLon(pointString: string): { latitude: number; longitude: number } {
// Regular expression to match the numbers within the parentheses
const regex = /POINT\(([-+]?\d+\.?\d*)\s+([-+]?\d+\.?\d*)\)/;
const match = pointString.match(regex);
if (match && match.length === 3) {
// The first captured group is the longitude, the second is the latitude
const longitude = parseFloat(match[1]);
const latitude = parseFloat(match[2]);
// Check if parsing was successful and resulted in valid numbers
if (!isNaN(longitude) && !isNaN(latitude)) {
return { latitude, longitude };
}
}
return { latitude: 0, longitude: 0 }; // Return null if the string format is not as expected or parsing fails
}

View File

@@ -15,5 +15,5 @@ export default defineConfig({
host: '0.0.0.0',
port: 3000,
},
base: '/elegent',
base: '/',
});