Compare commits

...

3 Commits

Author SHA1 Message Date
a3675c2585 inital checkin before beta launch 2025-10-15 15:09:37 -05:00
86b1eaf6f7 big update 2025-08-16 12:57:07 -05:00
508d1179dc adding a gitignore 2025-08-16 12:56:55 -05:00
137 changed files with 21637 additions and 2691 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/dist # Common output directory for TypeScript builds
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.tsbuildinfo # TypeScript build info file
.eslintcache # ESLint cache file

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

@@ -0,0 +1,2 @@
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
REAL_ESTATE_API_KEY=AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70

View File

@@ -0,0 +1,3 @@
VITE_API_URL=https://beta.backend.ditchtheagent.com/api/
ENABLE_REGISTRATION=true
USE_LIVE_DATA=false

View File

@@ -0,0 +1,3 @@
VITE_API_URL=http://127.0.0.1:8010/api/
ENABLE_REGISTRATION=true
USE_LIVE_DATA=false

View File

@@ -0,0 +1,3 @@
VITE_API_URL=https://backend.ditchtheagent.com/api/
ENABLE_REGISTRATION=false
USE_LIVE_DATA=true

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,59 @@
{
"name": "mui-dta-dashboard",
"private": true,
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
"deploy": "gh-pages -d dist"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/material": "^5.15.14",
"@mui/x-data-grid": "^7.2.0",
"@mui/x-data-grid-generator": "^7.2.0",
"axios": "^1.10.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"echarts-for-react": "^3.0.2",
"formik": "^2.4.6",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.525.0",
"material-ui-popup-state": "^5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"simplebar-react": "^3.2.5"
},
"devDependencies": {
"@iconify/react": "^4.1.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}
{
"name": "mui-dta-dashboard",
"private": true,
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:beta": "tsc && vite build --mode beta",
"build:prod": "tsc && vite build --mode production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
"deploy": "gh-pages -d dist"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^7.2.0",
"@mui/x-data-grid-generator": "^7.2.0",
"@mui/x-date-pickers": "^8.11.2",
"@react-google-maps/api": "^2.20.7",
"@types/zxcvbn": "^4.4.5",
"@vis.gl/react-google-maps": "^1.5.4",
"axios": "^1.10.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"echarts-for-react": "^3.0.2",
"eslint-config-prettier": "^10.1.8",
"formik": "^2.4.6",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.525.0",
"material-ui-popup-state": "^5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"simplebar-react": "^3.2.5",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@iconify/react": "^4.1.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@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",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

@@ -1,96 +1,105 @@
import axios from "axios"
import Cookies from 'js-cookie'
import axios from 'axios';
import Cookies from 'js-cookie';
const baseURL = import.meta.env.VITE_API_URL;
console.log(baseURL);
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': import.meta.env.REAL_ESTATE_API_KEY,
'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'),
'Content-Type': 'application/json',
'Accept': 'application/json',
}
baseURL: baseURL,
timeout: 5000,
headers: {
Authorization: 'JWT ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
export const cleanAxiosInstance = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
baseURL: baseURL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
export const axiosInstanceCSRF = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
},
withCredentials: true,
}
);
baseURL: baseURL,
timeout: 5000,
headers: {
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
},
withCredentials: true,
});
axiosInstance.interceptors.request.use(config => {
config.timeout = 100000;
return config;
})
axiosInstance.interceptors.request.use((config) => {
config.timeout = 100000;
return config;
});
axiosInstance.interceptors.response.use(
response => response,
error => {
const originalRequest = error.config;
(response) => response,
(error) => {
const originalRequest = error.config;
// Prevent infinite loop
if (error.response.status === 401 && originalRequest.url === baseURL+'/token/refresh/') {
window.location.href = '/signin/';
//console.log('remove the local storage here')
return Promise.reject(error);
}
if(error.response.data.code === "token_not_valid" &&
error.response.status == 401 &&
error.response.statusText == 'Unauthorized')
{
const refresh_token = localStorage.getItem('refresh_token');
if (refresh_token){
const tokenParts = JSON.parse(atob(refresh_token.split('.')[1]));
const now = Math.ceil(Date.now() / 1000);
//console.log(tokenParts.exp)
if(tokenParts.exp > now){
return axiosInstance.post('/token/refresh/', {refresh: refresh_token}).then((response) => {
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
return axiosInstance(originalRequest);
}).catch(err => {
console.log(err)
});
}else{
console.log('Refresh token is expired');
window.location.href = '/signin/';
}
}else {
console.log('Refresh token not available');
window.location.href = '/signin/';
}
}
return Promise.reject(error);
// Prevent infinite loop
if (error.response.status === 401 && originalRequest.url === baseURL + '/token/refresh/') {
window.location.href = '/authentication/login/';
//console.log('remove the local storage here')
return Promise.reject(error);
}
);
if (
error.response.data.code === 'token_not_valid' &&
error.response.status == 401 &&
error.response.statusText == 'Unauthorized'
) {
const refresh_token = localStorage.getItem('refresh_token');
if (refresh_token) {
const tokenParts = JSON.parse(atob(refresh_token.split('.')[1]));
const now = Math.ceil(Date.now() / 1000);
//console.log(tokenParts.exp)
if (tokenParts.exp > now) {
return axiosInstance
.post('/token/refresh/', { refresh: refresh_token })
.then((response) => {
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
return axiosInstance(originalRequest);
})
.catch((err) => {
console.log(err);
});
} else {
console.log('Refresh token is expired');
window.location.href = '/authentication/login/';
}
} else {
console.log('Refresh token not available');
window.location.href = '/authentication/login/';
}
}
return Promise.reject(error);
},
);

View File

@@ -0,0 +1,28 @@
// 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 size={{ xs: 12, sm: 6, md: 4, lg: 3 }} 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,32 +30,26 @@ interface FloatingActionButtonProps {
const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
return (
<Fab
color="secondary"
aria-label="open chat"
onClick={onClick}
sx={{
position: 'fixed',
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
right: 24, // Equivalent to Tailwind's right-6 (24px)
zIndex: 50, // Equivalent to Tailwind's z-50
boxShadow: '0px 10px 15px -3px rgba(0,0,0,0.1), 0px 4px 6px -2px rgba(0,0,0,0.05)', // Tailwind shadow-lg
'&:hover': {
backgroundColor: '#1d4ed8', // Blue-700 equivalent
},
}}
>
<MessageSquareText size={24} />
</Fab>
<Fab
color="secondary"
aria-label="open chat"
onClick={onClick}
sx={{
position: 'fixed',
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
right: 24, // Equivalent to Tailwind's right-6 (24px)
zIndex: 50, // Equivalent to Tailwind's z-50
boxShadow: '0px 10px 15px -3px rgba(0,0,0,0.1), 0px 4px 6px -2px rgba(0,0,0,0.05)', // Tailwind shadow-lg
'&:hover': {
backgroundColor: '#1d4ed8', // Blue-700 equivalent
},
}}
>
<MessageSquareText size={24} />
</Fab>
);
};
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,51 +392,46 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
`}
</style>
</Box>
);
};
const FloatingChatButton = (): ReactElement =>
{
const [showChat, setShowChat] = useState<boolean>(false);
// State to control if the chat pane is minimized
const [isMinimized, setIsMinimized] = useState<boolean>(false);
const FloatingChatButton = (): ReactElement => {
const [showChat, setShowChat] = useState<boolean>(false);
// State to control if the chat pane is minimized
const [isMinimized, setIsMinimized] = useState<boolean>(false);
// Function to toggle the chat pane visibility
const toggleChat = () => {
setShowChat(!showChat);
// When opening, ensure it's not minimized
if (!showChat) {
setIsMinimized(false);
}
};
// Function to toggle the chat pane visibility
const toggleChat = () => {
setShowChat(!showChat);
// When opening, ensure it's not minimized
if (!showChat) {
setIsMinimized(false);
}
};
// Function to toggle minimize/maximize the chat pane
const toggleMinimize = () => {
setIsMinimized(!isMinimized);
};
// Function to toggle minimize/maximize the chat pane
const toggleMinimize = () => {
setIsMinimized(!isMinimized);
};
// Function to close the chat pane
const closeChat = () => {
setShowChat(false);
setIsMinimized(false); // Reset minimize state when closing
};
return(
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
// Function to close the chat pane
const closeChat = () => {
setShowChat(false);
setIsMinimized(false); // Reset minimize state when closing
};
return (
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
{/* Floating Action Button */}
{!showChat && <FloatingActionButton onClick={toggleChat} />}
<ChatPane
showChat={showChat}
isMinimized={isMinimized}
toggleMinimize={toggleMinimize}
closeChat={closeChat}
/>
</div>
</div>
);
};
)
}
export default FloatingChatButton;
export default FloatingChatButton;

View File

@@ -0,0 +1,106 @@
// 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 size={{ 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 size={{ 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,61 @@
import React from 'react';
import zxcvbn from 'zxcvbn';
import { Box, LinearProgress, Typography } from '@mui/material';
interface PasswordStrengthCheckerProps {
password?: string;
}
const PasswordStrengthChecker: React.FC<PasswordStrengthCheckerProps> = ({ password }) => {
const strength = password ? zxcvbn(password).score : 0;
const getStrengthLabel = () => {
switch (strength) {
case 0:
return 'Weak';
case 1:
return 'Fair';
case 2:
return 'Good';
case 3:
return 'Strong';
case 4:
return 'Very Strong';
default:
return '';
}
};
const getStrengthColor = () => {
switch (strength) {
case 0:
return 'error';
case 1:
return 'warning';
case 2:
return 'info';
case 3:
return 'success';
case 4:
return 'success';
default:
return 'grey';
}
};
return (
<Box>
<LinearProgress
variant="determinate"
value={(strength + 1) * 20}
color={getStrengthColor()}
/>
<Typography variant="caption" color="textSecondary">
{getStrengthLabel()}
</Typography>
</Box>
);
};
export default PasswordStrengthChecker;

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,36 @@
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
size={{ xs: 12, sm: 6, 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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,384 @@
import { AxiosResponse } from 'axios';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi';
//==import Grid from '@mui/material/Unstable_Grid2';
import {
Alert,
Button,
Grid,
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';
import SavedPropertiesTable from './SavedPropertiesTable';
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 [savedProperties, setSavedProperties] = useState<PropertiesAPI[]>([]);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
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);
}
};
const fetchSavedProperties = async () => {
try {
let expandedSavedProperties: PropertiesAPI[] = [];
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
await axiosInstance.get('/saved-properties/');
const requests = data.map((item) =>
axiosInstance.get(`/properties/${item.property}/?search=1`),
);
const responses = await Promise.all(requests);
expandedSavedProperties = responses.map((response) => response.data);
console.log(expandedSavedProperties);
setSavedProperties(expandedSavedProperties);
} catch (error) {
console.log(error);
}
};
const fetchDocuments = async () => {
try {
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
console.log('documents', data);
setDocuments(data);
} catch (error) {
console.log(error);
}
};
fetchProperties();
fetchOffers();
fetchBids();
fetchSavedProperties();
fetchDocuments();
}, []);
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
try {
const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`,
{
...updatedProperty,
owner: account.id,
},
);
const updatedProperties = properties.map((item) => {
if (item.id === data.id) {
return { ...item, ...data };
}
return item;
});
setProperties(updatedProperties);
setMessage({ type: 'success', text: 'Property has been updated' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleDeleteProperty = async (propertyId: number) => {
try {
await axiosInstance.delete(`/properties/${propertyId}/`);
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
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);
const savedPropertiesCardLength: number = savedProperties.length === 0 ? 6 : 12;
const documentsCardLength: number = documents.length === 0 ? 6 : 12;
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>
{account.tier === 'basic' && (
<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" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
</Grid>
)}
{/* Properties */}
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{properties.length > 0 && (
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
)}
{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={documentsCardLength}>
<Card sx={{ display: 'flex' }}>
<Stack direction="column">
<Typography variant="h4">Documents Requiring Attention</Typography>
</Stack>
{documents.length === 0 ? (
<Typography variant="caption">
There are no documents that require your attention at this point
</Typography>
) : (
<CardContent sx={{ flexGrow: 1 }}>
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
</CardContent>
)}
</Card>
</Grid>
<Grid xs={12} md={savedPropertiesCardLength}>
<Card>
<Stack direction="column">
<Stack direction="column">
<Typography variant="h4">Saved Properties</Typography>
<Typography variant="caption">Keep track of the properties you have saved</Typography>
</Stack>
<CardContent>
<SavedPropertiesTable savedProperties={savedProperties} />
</CardContent>
</Stack>
</Card>
</Grid>
<Grid xs={12} md={12}>
{account.tier === 'premium' ? (
<Card>
<Stack direction="column">
<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>
</Stack>
</Card>
) : (
<Card>
<Stack direction="column">
<Typography variant="h4">Video Progress</Typography>
<Typography variant="caption">
Upgrade to get access to FSBO educational videos
</Typography>
</Stack>
<CardActionArea>
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
)}
</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 size={{ 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 size={{ 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 size={{ 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 size={{ 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,97 @@
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Button,
Box,
} from '@mui/material';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
interface SavedPropertiesTableProps {
savedProperties: PropertiesAPI[];
}
const SavedPropertiesTable: React.FC<SavedPropertiesTableProps> = ({ savedProperties }) => {
const navigate = useNavigate();
const getUpcomingOpenHouseDate = (property: PropertiesAPI): string => {
if (!property.open_houses || property.open_houses.length === 0) {
return 'N/A';
}
const today = new Date();
const futureOpenHouses = property.open_houses.filter(
(openHouse) => new Date(openHouse.listed_date) >= today,
);
if (futureOpenHouses.length > 0) {
// Sort to get the soonest upcoming one
futureOpenHouses.sort(
(a, b) => new Date(a.listed_date).getTime() - new Date(b.listed_date).getTime(),
);
// Format the date for display
return new Date(futureOpenHouses[0].listed_date).toLocaleDateString();
}
return 'No upcoming dates';
};
if (savedProperties.length === 0) {
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" align="center">
You don't have any saved properties.
</Typography>
</Box>
);
}
return (
<TableContainer component={Paper} sx={{ mt: 4 }}>
<Table sx={{ minWidth: 650 }} aria-label="saved properties table">
<TableHead>
<TableRow>
<TableCell>Street Address</TableCell>
<TableCell>Status</TableCell>
<TableCell>Price</TableCell>
<TableCell>Upcoming Open House</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{savedProperties.map((property) => {
const displayPrice = property.listed_price
? `$${property.listed_price}`
: `$${property.market_value}`;
const openHouseDate = getUpcomingOpenHouseDate(property);
return (
<TableRow key={property.id}>
<TableCell>{property.street}</TableCell>
<TableCell>{property.property_status}</TableCell>
<TableCell>{displayPrice}</TableCell>
<TableCell>{openHouseDate}</TableCell>
<TableCell align="right">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/property/${property.id}/?search=1`)}
>
View Listing
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
};
export default SavedPropertiesTable;

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 size={{ 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 size={{ 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 size={{ 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 size={{ xs: 12 }}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid size={{ xs: 12, md: 12 }}>
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
</Grid>
</Grid>
</Container>
);
};
export default VendorDashboard;

View File

@@ -0,0 +1,75 @@
import {
Button,
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
Autocomplete,
TextField,
Paper,
} from '@mui/material';
import { ReactElement, useState } from 'react';
import { PropertiesAPI, DocumentAPI, UserAPI, BidAPI } from 'types';
import AttorneyDocumentDialog from './Dialog/AttorneyDocumentDialog';
import PropertyOwnerDocumentDialog from './Dialog/PropertyOwnerDocumentDialog';
import VendorDocumentDialog from './Dialog/VendorDocumentDialog';
export type DocumentDialogProps = {
showDialog: boolean;
closeDialog: () => void;
properties: PropertiesAPI[];
bids: BidAPI[];
};
type AddDocumentDialogProps = DocumentDialogProps & {
account: UserAPI;
};
const AddDocumentDialog = ({
showDialog,
closeDialog,
properties,
bids,
account,
}: AddDocumentDialogProps): ReactElement => {
if (account.user_type === 'property_owner') {
return (
<PropertyOwnerDocumentDialog
showDialog={showDialog}
closeDialog={closeDialog}
properties={properties}
bids={bids}
/>
);
} else if (account.user_type === 'vendor') {
return (
<VendorDocumentDialog
showDialog={showDialog}
closeDialog={closeDialog}
properties={properties}
bids={bids}
/>
);
} else if (account.user_type === 'attorney') {
return (
<AttorneyDocumentDialog
showDialog={showDialog}
closeDialog={closeDialog}
properties={properties}
bids={bids}
/>
);
} else {
return (
<Dialog open={showDialog} onClose={closeDialog}>
<Typography>Woops, we have encountered an error</Typography>
</Dialog>
);
}
};
export default AddDocumentDialog;

View File

@@ -0,0 +1,50 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Select,
Typography,
} from '@mui/material';
import { DocumentDialogProps } from '../AddDocumentDialog';
import { ReactElement } from 'react';
const AttorneyDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
const [documentType, setDocumentType] = useState<string>('');
const getDialogContent = (document_type: string) => {
if (document_type === 'offer') {
} else {
return <Typography>Please select a document type</Typography>;
}
};
return (
<Dialog open={showDialog} onClose={closeDialog}>
<DialogTitle>Create a new document</DialogTitle>
<DialogContent>
<Select
placeholder="Select Document Type"
onChange={(e) => setDocumentType(e.target.value)}
>
<MenuItem value="Select Document Type">Select Document Type</MenuItem>
<MenuItem value="attorney_engagment_letter">Attorney Engagment Letter</MenuItem>
<MenuItem value="sellers_disclosure">Seller Disclosure</MenuItem>
<MenuItem value="closing_document">Closing Document</MenuItem>
<MenuItem value="title_report">Title Report</MenuItem>
<MenuItem value="closing_disclosure">Closing Disclosure</MenuItem>
</Select>
{getDialogContent(documentType)}
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
Create
</Button>
</DialogActions>
</Dialog>
);
};
export default AttorneyDocumentDialog;

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Autocomplete,
Box,
Typography,
Grid,
CircularProgress,
InputAdornment,
Select,
MenuItem,
} from '@mui/material';
import { formatCurrency } from 'utils';
import { BidAPI, PropertiesAPI, VendorAPI } from 'types';
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
export interface HomeImprovementReceiptData {
propertyId: number;
vendorId: number | null;
dateOfWork: string;
description: string;
cost: number;
receiptFile?: File | null;
}
export interface HomeImprovementReceiptDialogContentHandle {
submitForm: () => void;
}
interface HomeImprovementReceiptDialogProps {
onSubmit: (docType: PropertyOwnerDocumentType, receipt: HomeImprovementReceiptData) => void;
properties: PropertiesAPI[]; // The specific property for which the receipt is being submitted
vendors: VendorAPI[]; // List of available vendors for autocomplete
bids: BidAPI[];
homeImprovmentErrors: { [key: string]: string };
loadingVendors?: boolean; // Optional: indicate if vendors are still loading
}
const HomeImprovementReciptDialogContent = forwardRef<
HomeImprovementReceiptDialogContentHandle,
HomeImprovementReceiptDialogProps
>(({ onSubmit, properties, vendors, bids, homeImprovmentErrors, loadingVendors = false }, ref) => {
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
const [selectedBid, setSelectedBid] = useState<BidAPI | null>(null);
const [selectedVendor, setSelectedVendor] = useState<VendorAPI | null>(null);
const [dateOfWork, setDateOfWork] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [cost, setCost] = useState<string>('');
// const [receiptFile, setReceiptFile] = useState<File | null>(null); // For file upload
// Reset form when dialog opens/closes
useEffect(() => {
if (!open) {
setSelectedVendor(null);
setDateOfWork('');
setDescription('');
setCost('');
// setReceiptFile(null);
}
}, [open]);
const submitForm = () => {
// Basic validation
if (!selectedProperty) {
return;
} else if (!selectedProperty.id || !selectedVendor || !dateOfWork || !cost) {
return;
}
const receiptData: HomeImprovementReceiptData = {
propertyId: selectedProperty?.id,
vendorId: selectedVendor.user.id,
dateOfWork,
description,
cost: parseFloat(cost),
// receiptFile, // Include if handling file upload
};
onSubmit('contractor_recipt', receiptData);
};
useImperativeHandle(ref, () => ({
submitForm,
}));
console.log(properties);
const getVendorOptionLabel = (option: VendorAPI) =>
`${option.business_name} (${option.business_type})`;
return (
<Grid size={{ xs: 12, md: 12 }}>
<Select
value={selectedProperty?.id || 'Pick a property'}
placeholder="Pick a property"
onChange={(e) => {
console.log(e.target.value);
console.log(properties);
console.log(properties.find((property) => property.id === Number(e.target.value)));
const foundProperty = properties.find(
(property) => property.id === Number(e.target.value),
);
setSelectedProperty(foundProperty);
}}
>
{properties.map((property) => (
<MenuItem value={property.id}>{property.address}</MenuItem>
))}
</Select>
{selectedProperty && (
<>
<Typography variant="subtitle1" gutterBottom>
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
{selectedProperty.state} {selectedProperty.zip_code}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Record details of home improvements, maintenance, or repairs.
</Typography>
{bids.length > 0 ? (
<>
<Select
value={selectedBid}
label="Bid"
onChange={(e) => {
const foundBid = bids.find((bid) => bid.id === Number(e.target.value));
setSelectedBid(foundBid);
}}
>
{bids.map((bid) => (
<MenuItem value={bid.id}>{bid.description}</MenuItem>
))}
</Select>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<Autocomplete
options={vendors}
getOptionLabel={getVendorOptionLabel}
loading={loadingVendors}
onChange={(event, newValue) => {
setSelectedVendor(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Select Vendor"
variant="outlined"
fullWidth
required
margin="normal"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loadingVendors ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputAdornments?.end}
</React.Fragment>
),
}}
/>
)}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Date of Work"
type="date"
value={dateOfWork}
onChange={(e) => setDateOfWork(e.target.value)}
fullWidth
required
margin="normal"
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Description of Work Performed"
multiline
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
fullWidth
margin="normal"
helperText="e.g., Replaced water heater, Repaired leaky faucet, Painted living room."
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Cost of Work"
type="number"
value={cost}
onChange={(e) => setCost(e.target.value)}
fullWidth
required
margin="normal"
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
/>
</Grid>
{/* Uncomment and implement if you need file uploads */}
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" sx={{ mt: 1, mb: 0.5 }}>
Upload Receipt (Optional)
</Typography>
<input
type="file"
onChange={(e) => setReceiptFile(e.target.files ? e.target.files[0] : null)}
/>
</Grid>
</Grid>
</>
) : (
<Typography>There are no bids to put the recipt against</Typography>
)}
</>
)}
</Grid>
);
});
export default HomeImprovementReciptDialogContent;

View File

@@ -0,0 +1,329 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import {
TextField,
Button,
Autocomplete,
Box,
Typography,
Grid,
CircularProgress,
InputAdornment,
} from '@mui/material';
import { formatCurrency } from 'utils'; // Assuming this utility function is available
import { axiosInstance } from '../../../../../../axiosApi'; // Assuming axiosInstance is configured
import { PropertiesAPI } from 'types'; // Assuming PropertiesAPI is defined in your types file
import { AxiosResponse } from 'axios';
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
// Define the shape of the offer data to be submitted
export interface OfferData {
propertyId: number | null;
offerPrice: number;
closingDate?: string;
closingDays?: number;
contingencies: string;
parent_offer?: OfferData;
}
// Define the handle for the ref that the parent can use to interact with this component
export interface OfferDialogContentHandle {
submitForm: () => void;
}
// Define the type for properties passed down
type OfferPropertyType = {
address: string;
marketValue: string;
property_id: number;
};
// Interface for props of OfferDialogContent
interface OfferFormDialogProps {
onSubmit: (docType: PropertyOwnerDocumentType, offer: OfferData) => void; // Callback function to send data to parent
offerErrors: { [key: string]: string };
}
const OfferDialogContent = forwardRef<OfferDialogContentHandle, OfferFormDialogProps>(
({ onSubmit, offerErrors }, ref) => {
const [inputValue, setInputValue] = useState('');
const [options, setOptions] = useState<string[]>([]);
const [filteredProperties, setFilteredProperties] = useState<OfferPropertyType[]>([]);
const [loadingProperties, setLoadingProperties] = useState<boolean>(false);
// State for form fields
const [selectedProperty, setSelectedProperty] = useState<OfferPropertyType | null>(null);
const [offerPrice, setOfferPrice] = useState<string>('');
const [closingDate, setClosingDate] = useState<string>('');
const [closingDays, setClosingDays] = useState<string>('30'); // Default to 30 days
const [contingencies, setContingencies] = useState<string>('');
// Mortgage Calculation States
const [loanInterestRate, setLoanInterestRate] = useState<string>('7.0'); // Annual percentage
const [loanTermYears, setLoanTermYears] = useState<string>('30'); // Years
const [downPaymentPercentage, setDownPaymentPercentage] = useState<string>('20'); // Percentage of offer price
const [estimatedMonthlyPayment, setEstimatedMonthlyPayment] = useState<string>('N/A');
// Handle input change for the Autocomplete component, fetching properties
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
setInputValue(newInputValue);
if (newInputValue) {
setLoadingProperties(true);
try {
// Fetch properties based on search input
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(
`/properties/?search=${newInputValue}`,
);
// Map API response to OfferPropertyType
const mappedResults: OfferPropertyType[] = data.map((item) => ({
address: item.address,
marketValue: item.market_value,
property_id: item.id,
}));
setFilteredProperties(mappedResults);
setOptions(mappedResults.map((item) => item.address)); // Set options for Autocomplete
} catch (error) {
console.error('Failed to fetch properties:', error);
setOptions([]);
setFilteredProperties([]);
} finally {
setLoadingProperties(false);
}
} else {
setOptions([]);
setFilteredProperties([]);
}
};
// Effect to recalculate mortgage whenever relevant inputs change
useEffect(() => {
const calculateMortgage = () => {
const price = parseFloat(offerPrice);
const interestRate = parseFloat(loanInterestRate);
const termYears = parseFloat(loanTermYears);
const downPaymentPct = parseFloat(downPaymentPercentage);
// Validate inputs
if (
isNaN(price) ||
price <= 0 ||
isNaN(interestRate) ||
isNaN(termYears) ||
termYears <= 0 ||
isNaN(downPaymentPct)
) {
setEstimatedMonthlyPayment('N/A');
return;
}
const downPaymentAmount = price * (downPaymentPct / 100);
const principalLoanAmount = price - downPaymentAmount;
if (principalLoanAmount <= 0) {
setEstimatedMonthlyPayment(formatCurrency(0));
return;
}
const monthlyInterestRate = interestRate / 100 / 12;
const numberOfPayments = termYears * 12;
let monthlyPayment = 0;
if (monthlyInterestRate === 0) {
// Handle zero interest rate scenario
monthlyPayment = principalLoanAmount / numberOfPayments;
} else {
// Standard mortgage formula
monthlyPayment =
(principalLoanAmount *
monthlyInterestRate *
Math.pow(1 + monthlyInterestRate, numberOfPayments)) /
(Math.pow(1 + monthlyInterestRate, numberOfPayments) - 1);
}
setEstimatedMonthlyPayment(formatCurrency(monthlyPayment));
};
calculateMortgage();
}, [offerPrice, loanInterestRate, loanTermYears, downPaymentPercentage]);
// Function to handle form submission
const submitForm = () => {
// Basic validation
if (!selectedProperty || !offerPrice) {
console.error('Validation Error: Please select a property and enter an offer price.');
// In a real app, you would display a user-friendly error message here (e.g., Snackbar)
return;
}
// Construct the offer data object
const formData: OfferData = {
propertyId: selectedProperty.property_id,
property: selectedProperty.property_id,
offer_price: parseFloat(offerPrice),
closing_date: closingDate || undefined,
closing_days: closingDays ? parseInt(closingDays) : undefined,
contingencies,
};
// Call the onSubmit prop to pass data to the parent
onSubmit('offer', formData);
};
// Expose the submitForm function via useImperativeHandle so parent can call it
useImperativeHandle(ref, () => ({
submitForm,
}));
console.log(selectedProperty);
return (
<Grid container spacing={3}>
{/* Offer Form Section */}
<Grid size={{ xs: 12, md: 7 }}>
<Autocomplete
value={selectedProperty?.address || null} // Display selected property address or null
options={options}
loading={loadingProperties}
onChange={(event, newValue) => {
// Find the selected property object from filteredProperties
const selectedAddr = filteredProperties.find((item) => item.address === newValue);
setSelectedProperty(selectedAddr || null); // Set selected property or null
}}
onInputChange={handleInputChange}
noOptionsText={'Type the address to search for'}
renderInput={(params) => (
<TextField
{...params}
label="Select Property"
variant="outlined"
fullWidth
required
margin="normal"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loadingProperties ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment} {/* Pass existing endAdornment */}
</React.Fragment>
),
}}
/>
)}
/>
<TextField
label="Offer Price"
type="number"
value={offerPrice}
onChange={(e) => setOfferPrice(e.target.value)}
fullWidth
required
margin="normal"
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
helperText={offerErrors.offer_price}
error={!!offerErrors.offer_price}
/>
<TextField
label="Closing Date (Optional)"
type="date"
value={closingDate}
onChange={(e) => setClosingDate(e.target.value)}
fullWidth
margin="normal"
InputLabelProps={{
shrink: true,
}}
helperText={offerErrors.closing_date}
error={!!offerErrors.closing_date}
/>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 1 }}>
OR
</Typography>
<TextField
label="Closing Duration (Days)"
type="number"
value={closingDays}
onChange={(e) => setClosingDays(e.target.value)}
fullWidth
margin="normal"
helperText={offerErrors.closing_days}
error={!!offerErrors.closing_days}
/>
<TextField
label="Other Contingencies (e.g., inspection, financing)"
multiline
rows={4}
value={contingencies}
onChange={(e) => setContingencies(e.target.value)}
fullWidth
margin="normal"
helperText={offerErrors.contingencies}
error={!!offerErrors.contingencies}
/>
</Grid>
{/* Estimated Mortgage Payment Section */}
<Grid size={{ xs: 12, md: 5 }}>
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2, bgcolor: '#f5f5f5' }}>
<Typography variant="h6" gutterBottom>
Estimated Mortgage Payment
</Typography>
<TextField
label="Down Payment (%)"
type="number"
value={downPaymentPercentage}
onChange={(e) => setDownPaymentPercentage(e.target.value)}
fullWidth
margin="normal"
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
/>
<TextField
label="Annual Interest Rate (%)"
type="number"
value={loanInterestRate}
onChange={(e) => setLoanInterestRate(e.target.value)}
fullWidth
margin="normal"
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
/>
<TextField
label="Loan Term (Years)"
type="number"
value={loanTermYears}
onChange={(e) => setLoanTermYears(e.target.value)}
fullWidth
margin="normal"
/>
<Box
sx={{
mt: 2,
p: 2,
border: '1px dashed #bdbdbd',
borderRadius: 1,
bgcolor: '#ffffff',
}}
>
<Typography variant="subtitle1">Estimated Monthly Payment (P&I):</Typography>
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
{estimatedMonthlyPayment}
</Typography>
<Typography variant="caption" color="textSecondary">
*This is an estimate for Principal & Interest only. It does not include taxes,
insurance, or HOA fees.
</Typography>
</Box>
</Box>
</Grid>
</Grid>
);
},
);
export default OfferDialogContent;

View File

@@ -0,0 +1,217 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Select,
Typography,
} from '@mui/material';
import { DocumentDialogProps } from '../AddDocumentDialog';
import React, { ReactElement, useState, useRef } from 'react';
import OfferDialogContent, { OfferDialogContentHandle, OfferData } from './OfferDialogContent'; // Import the handle and data types
import SellerDisclousureDialogContent, {
SellerDisclousureDialogContentHandle,
SellerDisclousureData,
} from './SellerDisclousureDialogContent';
import HomeImprovementReciptDialogContent, {
HomeImprovementReceiptData,
HomeImprovementReceiptDialogContentHandle,
} from './HomeImprovementReciptDialogContent';
import { axiosInstance } from '../../../../../../axiosApi';
// Assuming BidAPI, PropertiesAPI, VendorAPI are defined in types or available
export type PropertyOwnerDocumentType = 'offer' | 'seller_disclosure' | 'home_improvement_receipt';
// Custom message box component for user feedback
const MessageBox = ({ message, onClose }: { message: string; onClose: () => void }) => (
<Dialog open={true} onClose={onClose}>
<DialogTitle>Error</DialogTitle>
<DialogContent>
<Typography>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>OK</Button>
</DialogActions>
</Dialog>
);
const PropertyOwnerDocumentDialog = ({
showDialog,
closeDialog,
properties,
bids,
}: DocumentDialogProps): ReactElement => {
const [documentType, setDocumentType] = useState<PropertyOwnerDocumentType | null>(null);
const offerDialogRef = useRef<OfferDialogContentHandle>(null); // Ref for OfferDialogContent
const sellerDisclosureRef = useRef<SellerDisclousureDialogContentHandle>(null);
const homeImprovementRef = useRef<HomeImprovementReceiptDialogContentHandle>(null);
const [submittedOfferData, setSubmittedOfferData] = useState<OfferData | null>(null); // State to hold submitted data
const [showError, setShowError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [homeImprovmentErrors, setHomeImprovementErrors] = useState<{ [key: string]: string }>({});
const [sellerDisclosureErrors, setSellerDisclosureErrors] = useState<{ [key: string]: string }>(
{},
);
const [offerErrors, setOfferErrors] = useState<{ [key: string]: string }>({});
// Callback function to receive data from OfferDialogContent
const handleOfferSubmit = async (
docType: PropertyOwnerDocumentType,
data: OfferData | SellerDisclousureData | HomeImprovementReceiptData,
) => {
if (docType === 'offer') {
console.log(data);
try {
const response = await axiosInstance.post('/documents/upload/', {
document_type: docType,
...data,
});
if (response.error) {
setOfferErrors(error.response.data.errors);
}
closeDialog(); // Close the dialog after successful submission
} catch (error) {
console.log(error.response.data.errors);
setOfferErrors(error.response.data.errors);
}
} else if (docType === 'seller_disclosure') {
console.log(data);
try {
const response = await axiosInstance.post('/documents/upload/', {
document_type: docType,
...data,
});
console.log(response);
if (response.error) {
setSellerDisclosureErrors(response.error);
}
closeDialog(); // Close the dialog after successful submission
} catch (error) {
console.log(error.response.data.errors);
setSellerDisclosureErrors(error.response.data.errors);
}
console.log('SellerDisclousureData Data Received in PropertyOwnerDocumentDialog:', data);
} else if (docType === 'home_improvement_receipt') {
console.log('HomeImprovementReceiptData Data Received in PropertyOwnerDocumentDialog:', data);
closeDialog(); // Close the dialog after successful submission
}
// Here you would typically send this 'data' to your backend API
};
// Function to render the appropriate dialog content based on document type
const getDialogContent = (document_type: PropertyOwnerDocumentType | null): ReactElement => {
if (document_type === 'offer') {
return (
<OfferDialogContent
ref={offerDialogRef} // Pass the ref to the OfferDialogContent
onSubmit={handleOfferSubmit} // Pass the callback for submitted data
offerErrors={offerErrors}
/>
);
} else if (document_type === 'seller_disclosure') {
// If SellerDisclousureDialogContent also needs to pass data, it would need
// a similar ref and onSubmit prop setup.
return (
<SellerDisclousureDialogContent
ref={sellerDisclosureRef}
onSubmit={handleOfferSubmit}
properties={properties} // Pass properties down
sellerDisclosureErrors={sellerDisclosureErrors}
/>
);
} else if (document_type === 'home_improvement_receipt') {
// Similar to offer, if HomeImprovementReciptDialogContent needs to pass data.
return (
<HomeImprovementReciptDialogContent
ref={homeImprovementRef} // Example: needs its own ref
onSubmit={handleOfferSubmit} // Example: needs its own handler
properties={properties}
vendors={[]}
bids={bids}
homeImprovmentErrors={homeImprovmentErrors}
/>
);
} else {
return <Typography>Please select a document type</Typography>;
}
};
// Store the currently active dialog content as a React element
const dialogContentElement = getDialogContent(documentType);
// Function to handle the "Create" button click in the parent dialog
const handleCreate = async () => {
if (documentType === 'offer') {
if (offerDialogRef.current) {
// Trigger the submitForm method exposed by OfferDialogContent via the ref
offerDialogRef.current.submitForm();
} else {
setErrorMessage('Offer dialog content not ready. Please select a document type.');
setShowError(true);
}
} else if (documentType === 'seller_disclosure') {
if (sellerDisclosureRef.current) {
sellerDisclosureRef.current.submitForm();
} else {
setErrorMessage('Seller Disclosure submission not implemented yet.');
setShowError(true);
}
} else if (documentType === 'contractor_recipt') {
// Placeholder for home improvement receipt submission
if (homeImprovementRef.current) {
homeImprovementRef.current.submitForm();
} else {
setErrorMessage('Home Improvement Recipt submission not implemented yet.');
setShowError(true);
}
} else {
// If no document type is selected
setErrorMessage('Please select a document type before creating.');
setShowError(true);
}
};
return (
<Dialog open={showDialog} onClose={closeDialog}>
<DialogTitle>Create a new document</DialogTitle>
<DialogContent>
<Select
value={documentType || ''} // Control the value of the Select component
onChange={(e) => {
if (
e.target.value === 'offer' ||
e.target.value === 'seller_disclosure' ||
e.target.value === 'contractor_recipt'
) {
setDocumentType(e.target.value as PropertyOwnerDocumentType);
}
}}
displayEmpty // Allows the placeholder to be displayed when value is empty
sx={{ mb: 2, minWidth: 200 }} // Add some margin-bottom for spacing
>
<MenuItem value="" disabled>
Select Document Type
</MenuItem>
<MenuItem value="offer">Offer</MenuItem>
<MenuItem value="seller_disclosure">Seller Disclosure</MenuItem>
<MenuItem value="contractor_recipt">Home Improvement Recipt</MenuItem>
</Select>
{dialogContentElement} {/* Render the selected dialog content */}
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button variant="contained" color="primary" sx={{ ml: 'auto' }} onClick={handleCreate}>
Create
</Button>
</DialogActions>
{/* Render the message box if there's an error */}
{showError && <MessageBox message={errorMessage} onClose={() => setShowError(false)} />}
</Dialog>
);
};
export default PropertyOwnerDocumentDialog;

View File

@@ -0,0 +1,358 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Select,
Typography,
Grid,
TextField,
Checkbox,
FormControlLabel,
FormGroup,
Box,
FormControl,
FormHelperText,
} from '@mui/material';
import { DocumentDialogProps } from '../AddDocumentDialog';
import { ReactElement, forwardRef, useImperativeHandle, useState } from 'react';
import { PropertiesAPI } from 'types';
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
export interface SellerDisclousureData {
generalDefects: string;
roofCondition: string;
roofAge: string;
knownRoofLeaks: boolean;
plumbingIssues: string;
electricalIssues: string;
hvacCondition: string;
hvacAge: string;
knownLeadPaint: boolean;
knownAsbestos: boolean;
knownRadon: boolean;
pastWaterDamage: string;
structuralIssues: string;
neighborhoodNuisances: string;
propertyLineDisputes: string;
appliancesIncluded: string;
}
// Define the handle for the ref that the parent can use to interact with this component
export interface SellerDisclousureDialogContentHandle {
submitForm: () => void;
}
// Interface for props of OfferDialogContent
interface SellerDisclousureFormDialogProps {
onSubmit: (docType: PropertyOwnerDocumentType, disclosure: SellerDisclousureData) => void; // Callback function to send data to parent
properties: PropertiesAPI[];
sellerDisclosureErrors: { [key: string]: string };
}
const SellerDisclousureDialogContent = forwardRef<
SellerDisclousureDialogContentHandle,
SellerDisclousureFormDialogProps
>(({ onSubmit, properties, sellerDisclosureErrors }, ref) => {
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
const [generalDefects, setGeneralDefects] = useState<string>('');
const [roofCondition, setRoofCondition] = useState<string>('');
const [roofAge, setRoofAge] = useState<string>('');
const [knownRoofLeaks, setKnownRoofLeaks] = useState<boolean>(false);
const [plumbingIssues, setPlumbingIssues] = useState<string>('');
const [electricalIssues, setElectricalIssues] = useState<string>('');
const [hvacCondition, setHvacCondition] = useState<string>('');
const [hvacAge, setHvacAge] = useState<string>('');
const [knownLeadPaint, setKnownLeadPaint] = useState<boolean>(false);
const [knownAsbestos, setKnownAsbestos] = useState<boolean>(false);
const [knownRadon, setKnownRadon] = useState<boolean>(false);
const [pastWaterDamage, setPastWaterDamage] = useState<string>('');
const [structuralIssues, setStructuralIssues] = useState<string>('');
const [neighborhoodNuisances, setNeighborhoodNuisances] = useState<string>('');
const [propertyLineDisputes, setPropertyLineDisputes] = useState<string>('');
const [appliancesIncluded, setAppliancesIncluded] = useState<string>('');
const submitForm = () => {
const formData: SellerDisclousureData = {
property: selectedProperty?.id,
general_defects: generalDefects,
roof_condition: roofCondition,
roof_age: roofAge,
known_roof_leaks: knownRoofLeaks,
plumbing_issues: plumbingIssues,
electrical_issues: electricalIssues,
hvac_condition: hvacCondition,
hvac_age: hvacAge,
known_lead_paint: knownLeadPaint,
known_asbestos: knownAsbestos,
known_radon: knownRadon,
past_water_damage: pastWaterDamage,
structural_issues: structuralIssues,
neighborhood_nuisances: neighborhoodNuisances,
property_line_disputes: propertyLineDisputes,
appliances_included: appliancesIncluded,
};
onSubmit('seller_disclosure', formData);
};
useImperativeHandle(ref, () => ({
submitForm,
}));
console.log(sellerDisclosureErrors);
return (
<>
<Select
value={selectedProperty?.id || ''}
label="Property"
onChange={(e) => {
const foundProperty = properties.find(
(property) => property.id === Number(e.target.value),
);
setSelectedProperty(foundProperty);
}}
>
{properties.map((property) => (
<MenuItem value={property.id}>{property.address}</MenuItem>
))}
</Select>
{selectedProperty && (
<>
<Typography variant="subtitle1" gutterBottom>
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
{selectedProperty.state} {selectedProperty.zip_code}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Please disclose any known material defects or information about the property. Honesty is
crucial to avoid potential legal issues.
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12 }}>
<TextField
label="General Known Defects/Issues"
multiline
rows={3}
value={generalDefects}
onChange={(e) => setGeneralDefects(e.target.value)}
fullWidth
margin="normal"
helperText={`Describe any general issues not covered elsewhere (e.g., drainage problems, pest infestations). ${sellerDisclosureErrors.general_defects}`}
error={!!sellerDisclosureErrors.general_defects}
/>
</Grid>
{/* Property Systems Section */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
Roof Information
</Typography>
<TextField
label="Roof Condition"
value={roofCondition}
onChange={(e) => setRoofCondition(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.roof_condition}
helperText={sellerDisclosureErrors.roof_condition}
/>
<TextField
label="Approximate Roof Age (Years)"
type="number"
value={roofAge}
onChange={(e) => setRoofAge(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.roof_age}
helperText={sellerDisclosureErrors.roof_age}
/>
<FormControl error={!!sellerDisclosureErrors.known_roof_leaks}>
<FormControlLabel
control={
<Checkbox
checked={knownRoofLeaks}
onChange={(e) => setKnownRoofLeaks(e.target.checked)}
/>
}
label="Known Past or Present Roof Leaks?"
/>
<FormHelperText>{sellerDisclosureErrors.known_roof_leaks}</FormHelperText>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
Major Systems
</Typography>
<TextField
label="Plumbing System Issues"
multiline
rows={2}
value={plumbingIssues}
onChange={(e) => setPlumbingIssues(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.plumbing_issues}
helperText={sellerDisclosureErrors.plumbing_issues}
/>
<TextField
label="Electrical System Issues"
multiline
rows={2}
value={electricalIssues}
onChange={(e) => setElectricalIssues(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.electrical_issues}
helperText={sellerDisclosureErrors.electrical_issues}
/>
<TextField
label="HVAC (Heating, Ventilation, Air Conditioning) Condition"
value={hvacCondition}
onChange={(e) => setHvacCondition(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.hvac_condition}
helperText={sellerDisclosureErrors.hvac_condition}
/>
<TextField
label="Approximate HVAC Age (Years)"
type="number"
value={hvacAge}
onChange={(e) => setHvacAge(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.hvac_age}
helperText={sellerDisclosureErrors.hvac_age}
/>
</Grid>
{/* Environmental & Other Issues */}
<Grid size={{ xs: 12 }}>
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
Environmental & Other Issues
</Typography>
<FormGroup row>
<FormControl error={!!sellerDisclosureErrors.known_lead_paint}>
<FormControlLabel
control={
<Checkbox
checked={knownLeadPaint}
onChange={(e) => setKnownLeadPaint(e.target.checked)}
/>
}
label="Known Lead-Based Paint?"
/>
<FormHelperText>{sellerDisclosureErrors.known_lead_paint}</FormHelperText>
</FormControl>
<FormControl error={!!sellerDisclosureErrors.known_asbestos}>
<FormControlLabel
control={
<Checkbox
checked={knownAsbestos}
onChange={(e) => setKnownAsbestos(e.target.checked)}
/>
}
label="Known Asbestos?"
/>
<FormHelperText>{sellerDisclosureErrors.known_asbestos}</FormHelperText>
</FormControl>
<FormControl error={!!sellerDisclosureErrors.known_radon}>
<FormControlLabel
control={
<Checkbox
checked={knownRadon}
onChange={(e) => setKnownRadon(e.target.checked)}
/>
}
label="Known Radon?"
/>
<FormHelperText>{sellerDisclosureErrors.known_radon}</FormHelperText>
</FormControl>
</FormGroup>
<TextField
label="Past or Present Water Damage"
multiline
rows={2}
value={pastWaterDamage}
onChange={(e) => setPastWaterDamage(e.target.value)}
fullWidth
margin="normal"
helperText={`Describe location, cause, and remediation if applicable. ${sellerDisclosureErrors.past_water_damage}`}
error={!!sellerDisclosureErrors.past_water_damage}
/>
<TextField
label="Structural Issues (e.g., foundation, walls)"
multiline
rows={2}
value={structuralIssues}
onChange={(e) => setStructuralIssues(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.structural_issues}
helperText={sellerDisclosureErrors.structural_issues}
/>
<TextField
label="Neighborhood Nuisances (e.g., excessive noise, odors)"
multiline
rows={2}
value={neighborhoodNuisances}
onChange={(e) => setNeighborhoodNuisances(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.neighborhood_nuisances}
helperText={sellerDisclosureErrors.neighborhood_nuisances}
/>
<TextField
label="Property Line Disputes (Past or Present)"
multiline
rows={2}
value={propertyLineDisputes}
onChange={(e) => setPropertyLineDisputes(e.target.value)}
fullWidth
margin="normal"
error={!!sellerDisclosureErrors.past_water_damage}
helperText={sellerDisclosureErrors.past_water_damage}
/>
</Grid>
{/* Appliances Included */}
<Grid size={{ xs: 12 }}>
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
Appliances Included in Sale
</Typography>
<TextField
label="List all appliances included"
multiline
rows={3}
value={appliancesIncluded}
onChange={(e) => setAppliancesIncluded(e.target.value)}
fullWidth
margin="normal"
placeholder="e.g., Refrigerator, Washer, Dryer, Dishwasher..."
error={!!sellerDisclosureErrors.appliances_included}
helperText={sellerDisclosureErrors.appliances_included}
/>
</Grid>
</Grid>
<Box
sx={{ mt: 3, p: 2, border: '1px dashed #bdbdbd', borderRadius: 1, bgcolor: '#fffde7' }}
>
<Typography variant="body2" color="textSecondary">
By submitting this form, you acknowledge that the information provided is accurate to
the best of your knowledge and belief.
</Typography>
</Box>
</>
)}
</>
);
});
export default SellerDisclousureDialogContent;

View File

@@ -0,0 +1,13 @@
import { Dialog, Typography } from '@mui/material';
import { DocumentDialogProps } from '../AddDocumentDialog';
import { ReactElement } from 'react';
const VendorDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
return (
<Dialog open={showDialog} onClose={closeDialog}>
<Typography>Show the Vendor dialog</Typography>
</Dialog>
);
};
export default VendorDocumentDialog;

View File

@@ -0,0 +1,320 @@
// src/components/DocumentManager.tsx
import React, { useState, useEffect, useContext } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
List,
ListItem,
ListItemText,
Typography,
Box,
Grid,
Paper,
Container,
Stack,
} from '@mui/material';
import { AxiosResponse } from 'axios';
import { BidAPI, DocumentAPI, PropertiesAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi';
import { AccountContext } from 'contexts/AccountContext';
import DashboardLoading from '../Dashboard/DashboardLoading';
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import ArticleIcon from '@mui/icons-material/Article';
import { formatTimestamp } from 'utils';
import AddDocumentDialog from './AddDocumentDialog';
import SellerDisclosureDisplay from './SellerDisclosureDisplay';
import { PropertyOwnerDocumentType } from './Dialog/PropertyOwnerDocumentDialog';
import HomeImprovementReceiptDisplay from './HomeImprovementReceiptDisplay';
import OfferDisplay from './OfferDisplay';
import OfferNegotiationHistory from './OfferNegotiationHistory';
interface DocumentManagerProps {}
const getDocumentTitle = (docType: PropertyOwnerDocumentType) => {
if (docType === 'seller_disclosure') {
return 'Seller Disclosure';
} else if (docType === 'offer_letter') {
return 'Offer';
} else if (docType === 'home_improvement_receipt') {
return 'Home Improvement Receipt';
} else {
return docType;
}
};
const isMyTypeDocument = (upload_by: number, account_id: number, document_type: string) => {
console.log(upload_by, account_id, document_type);
if (document_type === 'offer_letter') {
return !(upload_by === account_id);
} else if (document_type === 'seller_disclosure') {
return upload_by === account_id;
}
};
const DocumentManager: React.FC<DocumentManagerProps> = ({}) => {
const { account, accountLoading } = useContext(AccountContext);
const [searchParams] = useSearchParams();
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
const [bids, setBids] = useState<BidAPI[]>([]);
const [selectedDocument, setSelectedDocument] = useState<DocumentAPI | null>(null);
const [showDialog, setShowDialog] = useState<boolean>(false);
const [selectedPropertyForDocument, setSelectedPropertyForDocument] =
useState<PropertiesAPI | null>(null);
const closeDialog = () => {
setShowDialog(false);
};
useEffect(() => {
const fetchDocuments = async () => {
try {
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
setDocuments(data);
} catch (error) {
console.error('Failed to fetch documents:', error);
}
};
const fetchProperties = async () => {
try {
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
setProperties(data);
} catch (error) {
console.error('Failed to fetch properties:', error);
}
};
const fetchBids = async () => {
try {
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
setBids(data);
} catch (error) {
console.error('Failed to fetch properties');
}
};
fetchDocuments();
fetchProperties();
fetchBids();
}, []);
useEffect(() => {
const selectedDocumentId = searchParams.get('selectedDocument');
if (selectedDocumentId) {
fetchDocument(parseInt(selectedDocumentId, 10));
}
}, [searchParams]);
useEffect(() => {
const fetchProperty = async () => {
console.log(account.id);
console.log(selectedDocument?.uploaded_by);
console.log(
isMyTypeDocument(selectedDocument?.uploaded_by, account.id, selectedDocument.document_type),
);
const url = isMyTypeDocument(
selectedDocument?.uploaded_by,
account.id,
selectedDocument.document_type,
)
? `/properties/${selectedDocument.property}/`
: `/properties/${selectedDocument.property}/?search=1`;
try {
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
setSelectedPropertyForDocument(data);
} catch (error) {
try {
const other_url =
url === `/properties/${selectedDocument.property}/`
? `/properties/${selectedDocument.property}/?search=1`
: `/properties/${selectedDocument.property}/`;
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(other_url);
setSelectedPropertyForDocument(data);
} catch (error) {}
}
};
fetchProperty();
}, [selectedDocument]);
console.log(documents);
const fetchDocument = async (documentId: number) => {
try {
const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`);
if (response?.data) {
setSelectedDocument(response.data);
}
} catch (error) {
setSelectedDocument(null);
console.error('Failed to fetch document:', error);
}
};
const getDocumentPaneComponent = (selectedDocument: DocumentAPI) => {
console.log(selectedDocument?.document_type);
if (!selectedDocument) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
p: 3,
color: 'grey.500',
}}
>
<ArticleIcon sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6">Select a document to view</Typography>
<Typography variant="body2">
Click on a document from the left panel to get started.
</Typography>
</Box>
);
} else if (selectedDocument.document_type === 'seller_disclosure') {
console.log(selectedDocument);
return (
<SellerDisclosureDisplay
disclosureData={selectedDocument.sub_document}
property={selectedPropertyForDocument}
/>
);
} else if (selectedDocument.document_type === 'home_improvement_receipt') {
return (
<HomeImprovementReceiptDisplay
receiptData={selectedDocument.sub_document}
property={selectedDocument.property}
/>
);
} else if (selectedDocument.document_type === 'offer_letter') {
return (
<OfferNegotiationHistory
property={selectedPropertyForDocument}
isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
offerData={selectedDocument}
/>
// <OfferDisplay
// offerData={selectedDocument.sub_document}
// property={selectedPropertyForDocument}
// isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
// documentId={selectedDocument.id}
// />
);
} else {
return <Typography>Not sure what this is</Typography>;
}
};
if (accountLoading) {
return <DashboardLoading />;
} else if (!account) {
return <DashboardErrorPage />;
}
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={{ height: '100%' }}>
{/* Left Panel: Document List */}
<Grid
size={{ 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' }}>
Documents
</Typography>
<Button
variant="contained"
color="primary"
sx={{ ml: 'auto', mb: 0 }}
onClick={() => setShowDialog(true)}
>
Create
</Button>
</Stack>
</Box>
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
{documents.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
<ArticleIcon sx={{ fontSize: 40, mb: 1 }} />
<Typography>There are no documents yet.</Typography>
</Box>
) : (
documents
.sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
)
.map((document) => (
<ListItem
key={document.id}
button
selected={selectedDocument?.id === document.id}
onClick={() => fetchDocument(document.id)}
sx={{ py: 1.5, px: 2 }}
>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{getDocumentTitle(document.document_type)}
</Typography>
}
secondary={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flexGrow: 1, pr: 1 }}
>
{document.description}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatTimestamp(document.updated_at)}
</Typography>
</Box>
}
/>
</ListItem>
))
)}
</List>
</Grid>
{/* Right Panel: Offer Detail */}
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
{getDocumentPaneComponent(selectedDocument)}
</Grid>
</Grid>
<AddDocumentDialog
showDialog={showDialog}
closeDialog={closeDialog}
account={account}
properties={properties}
bids={bids}
/>
</Paper>
</Container>
);
};
export default DocumentManager;

View File

@@ -0,0 +1,123 @@
import React from 'react';
import {
Box,
Typography,
Card,
CardContent,
Divider,
List,
ListItem,
ListItemText,
Grid,
} from '@mui/material';
// Interfaces for consistency across components
interface Property {
id: number;
address: string;
city: string;
state: string;
zip_code: string;
}
interface Vendor {
user: {
id: number;
email: string;
first_name: string;
last_name: string;
};
business_name: string;
business_type: string;
}
interface HomeImprovementReceiptData {
propertyId: number;
vendor: Vendor; // Assuming the full vendor object is passed for display
dateOfWork: string;
description: string;
cost: number;
// receiptFile?: string; // Assume URL string for display
}
interface HomeImprovementReceiptDisplayProps {
receiptData: HomeImprovementReceiptData;
property: Property;
}
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const HomeImprovementReceiptDisplay: React.FC<HomeImprovementReceiptDisplayProps> = ({
receiptData,
property,
}) => {
if (!receiptData || !property) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="h6" color="error">
No receipt information available to display.
</Typography>
</Box>
);
}
const { vendor, dateOfWork, description, cost } = receiptData;
return (
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
<CardContent>
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
Home Improvement Receipt
</Typography>
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
</Typography>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText primary="Vendor" secondary={vendor.business_name} />
</ListItem>
<ListItem>
<ListItemText primary="Vendor Type" secondary={vendor.business_type} />
</ListItem>
</List>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText
primary="Date of Work"
secondary={dateOfWork ? new Date(dateOfWork).toLocaleDateString() : 'N/A'}
/>
</ListItem>
<ListItem>
<ListItemText primary="Cost" secondary={formatCurrency(cost)} />
</ListItem>
</List>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Description of Work Performed
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{description || 'No description provided.'}
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export default HomeImprovementReceiptDisplay;

View File

@@ -0,0 +1,148 @@
import React from 'react';
import {
Box,
Typography,
Card,
CardContent,
Divider,
Grid,
List,
ListItem,
ListItemText,
CardActions,
Button,
} from '@mui/material';
import { axiosInstance } from '../../../../../axiosApi';
// Interfaces for consistency across components
interface Property {
id: number;
address: string;
city: string;
state: string;
zip_code: string;
}
interface OfferData {
propertyId: number;
offer_price: number;
closing_date?: string;
closing_days?: number;
contingencies: string;
status: string;
}
interface OfferDisplayProps {
offerData: OfferData;
property: Property;
isPropertyOwner: boolean;
onAccept: () => void;
onReject: () => void;
onCounter: () => void;
}
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const OfferDisplay: React.FC<OfferDisplayProps> = ({
offerData,
property,
isPropertyOwner,
onAccept,
onReject,
onCounter,
}) => {
if (!offerData || !property) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="h6" color="error">
No offer information available to display.
</Typography>
</Box>
);
}
const { offer_price, closing_date, closing_days, contingencies, status } = offerData;
const getClosingText = () => {
if (closing_days) {
return `${closing_days} days`;
}
if (closing_date) {
try {
const formattedDate = new Date(closing_date).toLocaleDateString();
return `By ${formattedDate}`;
} catch (error) {
console.error('Invalid closingDate format:', error);
return 'Invalid Date';
}
}
return 'Not specified';
};
const showActions =
(isPropertyOwner && (status === 'submitted' || status === 'pending')) ||
(!isPropertyOwner && status === 'countered');
return (
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
<CardContent>
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
Residential House Offer
</Typography>
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
</Typography>
<Typography variant="body1" color="text.primary">
Status: {status.charAt(0).toUpperCase() + status.slice(1)}
</Typography>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText primary="Offer Price" secondary={formatCurrency(offer_price)} />
</ListItem>
</List>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText primary="Closing Timeline" secondary={getClosingText()} />
</ListItem>
</List>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Other Contingencies
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{contingencies || 'No contingencies listed.'}
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
{showActions && (
<CardActions>
<Button variant="contained" onClick={onAccept}>
Accept
</Button>
<Button onClick={onCounter}>Counter</Button>
<Button onClick={onReject}>Reject</Button>
</CardActions>
)}
</Card>
);
};
export default OfferDisplay;

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { axiosInstance } from '../../../../../axiosApi';
import OfferDisplay, { OfferData } from './OfferDisplay';
import { DocumentAPI } from 'types';
// Interfaces for consistency across components
interface Property {
id: number;
address: string;
city: string;
state: string;
zip_code: string;
}
interface OfferNegotiationHistoryProps {
property: Property;
isPropertyOwner: boolean;
offerData: DocumentAPI;
}
const OfferNegotiationHistory: React.FC<OfferNegotiationHistoryProps> = ({
property,
isPropertyOwner,
offerData,
}) => {
const [offer, setOffer] = useState<OfferData>(offerData);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [expandedAccordion, setExpandedAccordion] = useState<number | false>(false);
const [history, setHistory] = useState<OfferData[]>([]);
// This useEffect builds the negotiation history
useEffect(() => {
let currentOffer = offerData.sub_document;
const negotiationHistory: OfferData[] = [];
// Traverse the parent chain until there is no parent
while (currentOffer) {
negotiationHistory.push(currentOffer);
currentOffer = currentOffer.parent_offer;
}
console.log(negotiationHistory);
const reversedHistory = negotiationHistory.reverse();
setHistory(reversedHistory);
if (reversedHistory.length > 0) {
setExpandedAccordion(reversedHistory[reversedHistory.length - 1].id);
}
}, [offerData]);
const handleChange = (panelId: number) => (event: React.SyntheticEvent, isExpanded: boolean) => {
console.log(panelId, isExpanded);
setExpandedAccordion(isExpanded ? panelId : false);
};
// const fetchOffers = async () => {
// setLoading(true);
// setError(null);
// try {
// // Assuming a new endpoint or a modified one that returns all offers for a property.
// // You may need to create this in your Django views.
// const response = await axiosInstance.get(`/documents/retrieve/?docId=${property.id}/`);
// setOffers(response.data);
// } catch (err) {
// console.error('Failed to fetch offers:', err);
// setError('Failed to load offers. Please try again later.');
// } finally {
// setLoading(false);
// }
// };
// console.log(property);
// useEffect(() => {
// if (property) {
// fetchOffers();
// }
// }, [property]);
const handleOfferAction = async (
documentId: number,
action: 'accept' | 'reject' | 'counter',
newOfferData?: any,
) => {
setLoading(true);
try {
const payload = {
document_id: documentId,
action: action,
...newOfferData, // Pass new offer data for a counter-offer
};
await axiosInstance.patch('/documents/retrieve/', payload);
//await fetchOffers(); // Re-fetch offers to see the updated status
} catch (err) {
console.error(`Failed to perform action '${action}':`, err);
setError('Failed to update offer status. Please try again.');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="h6" color="error">
{error}
</Typography>
</Box>
);
}
// if (offers.length === 0) {
// return (
// <Box sx={{ p: 3 }}>
// <Typography variant="h6" color="text.secondary">
// No offers have been submitted for this property.
// </Typography>
// </Box>
// );
// }
return (
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h2" gutterBottom>
Negotiation History
</Typography>
{history.length > 0 ? (
history.map((offer, index) => (
<Accordion
key={index}
expanded={expandedAccordion === index}
onChange={handleChange(index)}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>
Offer - {offer.status.toUpperCase()} - ${offer.offer_price}
</Typography>
</AccordionSummary>
<AccordionDetails>
<OfferDisplay
offerData={offer}
property={property}
isPropertyOwner={isPropertyOwner}
onAccept={() => handleOfferAction(offer.id, 'accept')}
onReject={() => handleOfferAction(offer.id, 'reject')}
onCounter={() => {
const newPrice = offer.offer_price * 0.95;
handleOfferAction(offer.id, 'counter', { offer_price: newPrice });
}}
/>
</AccordionDetails>
</Accordion>
))
) : (
<Typography>No negotiation history available.</Typography>
)}
</Box>
);
};
export default OfferNegotiationHistory;

View File

@@ -0,0 +1,230 @@
import React from 'react';
import {
Box,
Typography,
Card,
CardContent,
Divider,
Grid,
List,
ListItem,
ListItemText,
} from '@mui/material';
// Reuse the interfaces from the SellerDisclosureDialog for consistency
interface Property {
id: number;
address: string;
city: string;
state: string;
zip_code: string;
}
interface SellerDisclosureData {
propertyId: number;
general_defects: string;
roof_condition: string;
roof_age: number | null;
known_roof_leaks: boolean;
plumbing_issues: string;
electrical_issues: string;
hvac_condition: string;
hvac_age: number | null;
known_lead_paint: boolean;
known_asbestos: boolean;
known_radon: boolean;
past_water_damage: string;
structural_issues: string;
neighborhood_nuisances: string;
property_line_disputes: string;
appliances_included: string;
}
interface SellerDisclosureDisplayProps {
disclosureData: SellerDisclosureData;
property: Property; // The property associated with this disclosure
}
const SellerDisclosureDisplay: React.FC<SellerDisclosureDisplayProps> = ({
disclosureData,
property,
}) => {
console.log(disclosureData);
if (!disclosureData || !property) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="h6" color="error">
No disclosure information available to display.
</Typography>
</Box>
);
}
// Helper to format boolean values
const formatBoolean = (value: boolean) => (value ? 'Yes' : 'No');
const {
general_defects,
roof_condition,
roof_age,
known_roof_leaks,
plumbing_issues,
electrical_issues,
hvac_condition,
hvac_age,
known_lead_paint,
known_asbestos,
known_radon,
past_water_damage,
structural_issues,
neighborhood_nuisances,
property_line_disputes,
appliances_included,
} = disclosureData;
return (
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
<CardContent>
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
Seller's Property Disclosure
</Typography>
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
</Typography>
<Divider sx={{ my: 2 }} />
{/* General Information */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
General Information
</Typography>
<List dense>
<ListItem>
<ListItemText
primary="General Known Defects/Issues"
secondary={general_defects || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Appliances Included in Sale"
secondary={appliances_included || 'None listed'}
/>
</ListItem>
</List>
</Box>
<Divider sx={{ my: 2 }} />
{/* Property Systems */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Property Systems
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText
primary="Roof Condition"
secondary={roof_condition || 'Not disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Approximate Roof Age"
secondary={roof_age ? `${roof_age} years` : 'Not disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Known Roof Leaks"
secondary={formatBoolean(known_roof_leaks)}
/>
</ListItem>
</List>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<List dense>
<ListItem>
<ListItemText
primary="Plumbing System Issues"
secondary={plumbing_issues || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Electrical System Issues"
secondary={electrical_issues || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="HVAC Condition"
secondary={hvac_condition || 'Not disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Approximate HVAC Age"
secondary={hvac_age ? `${hvac_age} years` : 'Not disclosed'}
/>
</ListItem>
</List>
</Grid>
</Grid>
</Box>
<Divider sx={{ my: 2 }} />
{/* Environmental & Other Issues */}
<Box>
<Typography variant="h6" gutterBottom>
Environmental & Other Issues
</Typography>
<List dense>
<ListItem>
<ListItemText
primary="Known Lead-Based Paint"
secondary={formatBoolean(known_lead_paint)}
/>
</ListItem>
<ListItem>
<ListItemText primary="Known Asbestos" secondary={formatBoolean(known_asbestos)} />
</ListItem>
<ListItem>
<ListItemText primary="Known Radon" secondary={formatBoolean(known_radon)} />
</ListItem>
<ListItem>
<ListItemText
primary="Past or Present Water Damage"
secondary={past_water_damage || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Structural Issues"
secondary={structural_issues || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Neighborhood Nuisances"
secondary={neighborhood_nuisances || 'None disclosed'}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Property Line Disputes"
secondary={property_line_disputes || 'None disclosed'}
/>
</ListItem>
</List>
</Box>
</CardContent>
</Card>
);
};
export default SellerDisclosureDisplay;

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 size={{ xs: 12, sm: 6, md: 4 }} key={category.name}>
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
</Grid>
))}
</Grid>
);
};
export default CategoryGrid;

View File

@@ -1,214 +1,160 @@
import { useState, useEffect, ReactElement } from 'react';
import {
Card,
CardContent,
Stack,
Typography,
LinearProgress,
Box,
Grid,
Alert,
} from '@mui/material';
import { ReactElement } from 'react';
import { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material';
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { useNavigate } from 'react-router-dom'
import { renderProgress } from '@mui/x-data-grid-generator';
import { GridColDef } from '@mui/x-data-grid';
import { axiosInstance } from '../../../../../axiosApi';
import { VideoProgressAPI } from 'types';
type EducationInfoProps = {
title: string;
interface CategoryProgress {
categoryName: string;
totalProgress: number;
videoCount: number;
averageProgress: number;
}
interface Row {
id: number;
task: string;
progress: number; // Value from 0 to 100 for the progress bar
}
export const EducationInfoCards = () => {
return(
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
<EducationInfo title={'Education'} />
interface EducationInfoCardProps {
category: string;
progress: number;
totalVideos: number;
completedVideos: number;
}
const EducationInfoCard = ({
category,
progress,
totalVideos,
completedVideos,
}: EducationInfoCardCardProps): ReactElement => {
return (
<Card sx={{ boxShadow: 4, width: '100%' }}>
<CardContent>
<Stack spacing={1}>
<Typography variant="h6" component="h2">
{category}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 8, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary">
{completedVideos} of {totalVideos} videos complete
</Typography>
</Stack>
)
}
const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
const navigate = useNavigate();
const columns: GridColDef[] = [
{
field: 'id',
headerName: 'ID'
},
{
field: 'title',
headerName: 'Title',
flex: 1,
},
{
field: 'category',
headerName: 'Category',
flex: 1,
},
{
field: 'progress',
headerName: 'Progress',
flex: 1,
renderCell: (params: GridRenderCellParams<Row, number>) => {
const progressValue = params.value; // Access the progress value from the row data
return (
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant="determinate" value={progressValue} />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">{`${progressValue}%`}</Typography>
</Box>
</Box>
);
},
},
{
field: 'status',
headerName: 'Status',
flex: 1,
},
]
const rows = [
{
id: 1,
title: "How to Research Comparable Properties Like a Pro",
category: "Pricing Strategy",
progress: 100,
status: "COMPLETED",
},
{
id: 2,
title: "Understanding Price Per Square Foot in Your Neighborhood",
category: "Pricing Strategy",
progress: 100,
status: "COMPLETED",
},
{
id: 3,
title: "Psychological Pricing: Why $399,900 Works Better Than $400,000",
category: "Pricing Strategy",
progress: 100,
status: "COMPLETED",
},
{
id: 4,
title: "When and How to Adjust Your Asking Price",
category: "Pricing Strategy",
progress: 100,
status: "COMPLETED",
},
{
id: 5,
title: "Handling Lowball Offers: Strategies That Work",
category: "Pricing Strategy",
progress: 100,
status: "COMPLETED",
},
{
id: 6,
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
category: "Property Preparation",
progress: 90,
status: "IN_PROGRESS",
},
{
id: 7,
title: "DIY Curb Appeal Upgrades Under $500",
category: "Property Preparation",
progress: 80,
status: "IN_PROGRESS",
},
{
id: 8,
title: "Decluttering Secrets for Faster Sales",
category: "Property Preparation",
progress: 5,
status: "IN_PROGRESS",
},
{
id: 9,
title: "Professional Photography Tips Using Just Your Smartphone",
category: "Property Preparation",
progress: 50,
status: "IN_PROGRESS",
},
{
id: 10,
title: "Deep Cleaning Checklist Before Listing",
category: "Property Preparation",
progress: 50,
status: "IN_PROGRESS",
},
{
id: 11,
title: "How to stage a home",
category: "",
progress: 0,
status: "NOT_STARTED",
},
]
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="p" minWidth={100} color="text.primary">
{title}
</Typography>
</Stack>
<Divider />
<Stack
bgcolor="background.paper"
borderRadius={5}
width={1}
boxShadow={(theme) => theme.shadows[4]}
height={1}
>
<DataGrid
getRowHeight={() => 70}
rows={rows}
columns={columns}
onRowClick={(event) => navigate('lesson')}
/>
</Stack>
</CardContent>
</CardContent>
</Card>
)
);
};
}
export const EducationInfoCards = () => {
const [videoProgressData, setVideoProgressData] = useState<VideoProgressAPI[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
export default EducationInfo;
useEffect(() => {
// This is a mock function. Replace with your actual API call.
const fetchVideoProgress = async (): Promise<VideoProgressAPI[]> => {
try {
const { data } = await axiosInstance.get(`/videos/progress/`);
return data;
} catch (error) {
return [];
}
};
const loadData = async () => {
try {
setLoading(true);
const data = await fetchVideoProgress();
setVideoProgressData(data);
} catch (err) {
setError('Failed to fetch video progress data.');
console.error(err);
} finally {
setLoading(false);
}
};
loadData();
}, []);
const getCategoryProgress = () => {
if (!videoProgressData) return {};
const categories: { [key: string]: CategoryProgress } = {};
videoProgressData.forEach((item) => {
// Access the category name from the nested object
const categoryName = item.video.category?.name;
if (!categoryName) return; // Skip items without a category name
if (!categories[categoryName]) {
categories[categoryName] = {
categoryName: categoryName,
totalProgress: 0,
videoCount: 0,
averageProgress: 0,
};
}
categories[categoryName].totalProgress += item.progress;
categories[categoryName].videoCount++;
});
// Calculate average progress for each category
for (const key in categories) {
const categoryInfo = categories[key];
categoryInfo.averageProgress = Math.round(
categoryInfo.totalProgress / categoryInfo.videoCount,
);
}
return categories;
};
const categoryProgressData = getCategoryProgress();
if (loading) {
return <Typography>Loading progress...</Typography>;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (videoProgressData.length === 0) {
return <Typography>There are no videos yet</Typography>;
} else {
return (
<Stack
direction={{ sm: 'row' }}
justifyContent={{ sm: 'space-between' }}
gap={3.75}
flexWrap="wrap"
>
{Object.values(categoryProgressData).map((data, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<EducationInfoCard
category={data.categoryName}
progress={data.averageProgress}
totalVideos={data.videoCount}
completedVideos={
videoProgressData.filter(
(item) =>
item.video.category?.name === data.categoryName && item.progress === 100,
).length
}
/>
</Grid>
))}
</Stack>
);
}
};

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,56 @@
// 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 }) => {
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,312 @@
// 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;
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video, updateVideoItem }) => {
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);
await updateVideoItem(time, completed, progressData.progress, video.id);
} 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,93 @@
import {
Button,
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
Autocomplete,
TextField,
Paper,
} from '@mui/material';
import { ReactElement, useState, useEffect } from 'react';
import { axiosInstance } from '../../../../../axiosApi';
import { PropertiesAPI, VendorAPI, VendorItem } from 'types';
import { AxiosResponse } from 'axios';
type CreateOfferDialogProps = {
showDialog: boolean;
closeDialog: () => void;
createConversation: () => void;
setSelectedVendor: React.Dispatch<React.SetStateAction<VendorItem | null>>;
vendors: VendorAPI[];
selectedVendor: VendorAPI | null;
};
const CreateConversationDialogContent = ({
showDialog,
closeDialog,
createConversation,
setSelectedVendor,
vendors,
selectedVendor,
}: CreateOfferDialogProps): ReactElement => {
const [options, setOptions] = useState<string[]>(vendors.map((vendor) => vendor.business_name));
const [inputValue, setInputValue] = useState('');
useEffect(() => {
const filteredVendorNames: string[] = vendors.map((vendor) => {
return vendor.business_name;
});
setOptions(filteredVendorNames);
}, []);
const handleInputChange = async (event, newInputValue) => {
setInputValue(newInputValue);
if (newInputValue) {
const inputValue = newInputValue.toLowerCase();
const filteredVendors = vendors.filter((vendor) =>
vendor.business_name.toLowerCase().includes(inputValue),
);
const filteredVendorNames: string[] = filteredVendors.map((vendor) => {
return vendor.business_name;
});
setOptions(filteredVendorNames);
} else {
setOptions([]);
}
};
return (
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
<DialogTitle>Start a new Conversation</DialogTitle>
<DialogContent>
<Autocomplete
options={options} //vendors}
//getOptionLabel={//(option) => option.name}
onChange={(event, newValue) => {
const filteredVendors = vendors.filter((vendor) => vendor.business_name === newValue);
setSelectedVendor(filteredVendors[0]);
}}
inputValue={inputValue}
onInputChange={handleInputChange}
noOptionsText={'Type the vendor to search for'}
renderInput={(params) => (
<TextField {...params} label="Select a Vendor" variant="outlined" />
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button onClick={createConversation} color="primary" disabled={!selectedVendor}>
Create
</Button>
</DialogActions>
</Dialog>
);
};
export default CreateConversationDialogContent;

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,140 @@
// src/components/PropertyOwnerProfile/AddOpenHouseDialog.tsx
import React, { useState } from 'react';
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
MenuItem,
Autocomplete,
} from '@mui/material';
import { PropertiesAPI, OpenHouseAPI } from 'types'; // Ensure you have OpenHouseAPI in your types
import { DatePicker, TimePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
interface AddOpenHouseDialogProps {
open: boolean;
onClose: () => void;
onAddOpenHouse: (openHouse: Omit<OpenHouseAPI, 'id'> & { listed_date: string }) => void;
properties: PropertiesAPI[];
errors: { [key: string]: string };
}
const AddOpenHouseDialog: React.FC<AddOpenHouseDialogProps> = ({
open,
onClose,
onAddOpenHouse,
properties,
errors,
}) => {
const [propertyId, setPropertyId] = useState<number | ''>('');
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
const [startTime, setStartTime] = useState<Date | null>(new Date());
const [endTime, setEndTime] = useState<Date | null>(new Date());
const [date, setDate] = useState<Date | null>(new Date());
const [inputValue, setInputValue] = useState('');
const [options, setOptions] = useState<string[]>([]);
const handleAddOpenHouse = () => {
if (selectedProperty && startTime && endTime && date) {
const newOpenHouse = {
property: selectedProperty.id,
start_time: startTime.toTimeString().split(' ')[0],
end_time: endTime.toTimeString().split(' ')[0],
listed_date: date.toISOString().split('T')[0],
};
onAddOpenHouse(newOpenHouse);
// onClose();
// selectedProperty(null);
// setStartTime(new Date());
// setEndTime(new Date());
}
};
console.log(errors);
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
setInputValue(newInputValue);
if (newInputValue) {
const filteredPropertiesNames: string[] = properties.map((item) => {
return item.address;
});
setOptions(filteredPropertiesNames);
} else {
setOptions([]);
}
};
return (
<Dialog open={open} onClose={onClose}>
      <DialogTitle>Add New Open House</DialogTitle>     {' '}
<DialogContent>
       {' '}
<LocalizationProvider dateAdapter={AdapterDateFns}>
         {' '}
<Autocomplete
options={options}
value={selectedProperty?.address || ''}
onChange={(event, newValue) => {
const selectedAddr = properties.find((item) => item.address === newValue);
setSelectedProperty(selectedAddr || null);
}}
onInputChange={handleInputChange}
noOptionsText={'Type the address you want to set an open house for'}
renderInput={(params) => (
<TextField {...params} label="Select Property" fullWidth required margin="normal" />
)}
/>
<DatePicker
label="Date"
value={date}
onChange={(newValue) => setDate(newValue)}
slotProps={{ textField: { fullWidth: true } }}
helperText={errors.listed_date}
error={!!errors.listed_date}
/>
<TimePicker
label="Start Time"
value={startTime}
onChange={(newValue) => setStartTime(newValue)}
slotProps={{ textField: { fullWidth: true } }}
/>
<TimePicker
label="End Time"
value={endTime}
onChange={(newValue) => setEndTime(newValue)}
slotProps={{ textField: { fullWidth: true } }}
/>
       {' '}
</LocalizationProvider>
     {' '}
</DialogContent>
     {' '}
<DialogActions>
       {' '}
<Button onClick={onClose} color="primary">
          Cancel        {' '}
</Button>
       {' '}
<Button
onClick={handleAddOpenHouse}
color="primary"
variant="contained"
disabled={!selectedProperty}
>
          Add        {' '}
</Button>
     {' '}
</DialogActions>
   {' '}
</Dialog>
);
};
export default AddOpenHouseDialog;

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 = !import.meta.env.USE_LIVE_DATA;
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 size={{ 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 size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="City"
name="city"
value={newProperty.city}
onChange={handleInputChange}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="State"
name="state"
value={newProperty.state}
onChange={handleInputChange}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid size={{ 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 size={{ xs: 12 }}>
<TextField
fullWidth
label="Description"
name="description"
multiline
rows={3}
value={newProperty.description}
onChange={handleInputChange}
/>
</Grid>
<Grid size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ xs: 12 }}>
<TextField
fullWidth
label="Market Value"
name="market_value"
value={newProperty.market_value}
onChange={handleInputChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Loan Amount"
name="loan_amount"
value={newProperty.loan_amount}
onChange={handleInputChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Loan Term (years)"
name="loan_term"
type="number"
value={newProperty.loan_term || ''}
onChange={handleInputChange}
/>
</Grid>
<Grid size={{ 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 size={{ 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 size={{ 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,123 @@
import React, { useState, useEffect, ReactElement } from 'react';
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
import { AttorneyAPI, UserAPI } from '../../../../../types';
import ChangePasswordCard from './ChangePasswordCard';
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}
/>
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
</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 = !import.meta.env.USE_LIVE_DATA;
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 size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedAttorney.user.first_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedAttorney.user.last_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedAttorney.user.email}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ xs: 12 }}>
<TextField
fullWidth
label="Website URL"
name="website"
value={editedAttorney.website || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Biography"
name="bio"
multiline
rows={4}
value={editedAttorney.bio || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ 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 size={{ 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 size={{ 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,151 @@
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
Divider,
Grid,
LinearProgress,
TextField,
Typography,
} from '@mui/material';
import { axiosInstance } from 'axiosApi';
import { useState } from 'react';
import zxcvbn from 'zxcvbn';
const ChangePasswordCard = () => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordStrength, setPasswordStrength] = useState(0);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const password = e.target.value;
setNewPassword(password);
const strength = zxcvbn(password).score;
setPasswordStrength(strength);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrors({});
setMessage(null);
if (newPassword !== confirmPassword) {
setErrors({ confirmPassword: 'Passwords do not match' });
return;
}
if (passwordStrength < 2) {
setErrors({ newPassword: 'Password is too weak' });
return;
}
try {
await axiosInstance.post('/auth/password/change/', {
old_password: currentPassword,
new_password: newPassword,
});
setMessage({ type: 'success', text: 'Password updated successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordStrength(0);
} catch (error: any) {
setMessage({ type: 'error', text: 'Error updating password.' });
if (error.response && error.response.data) {
setErrors(error.response.data);
}
}
};
const passwordStrengthColor = () => {
switch (passwordStrength) {
case 0:
case 1:
return 'error';
case 2:
return 'warning';
case 3:
case 4:
return 'success';
default:
return 'grey';
}
};
return (
<Card>
<CardHeader title="Change Password" />
<Divider />
<CardContent>
<form onSubmit={handleSubmit}>
<Grid container spacing={2}>
{message && (
<Grid item xs={12}>
<Alert severity={message.type}>{message.text}</Alert>
</Grid>
)}
<Grid item xs={12}>
<TextField
fullWidth
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
error={!!errors.old_password}
helperText={errors.old_password}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="New Password"
type="password"
value={newPassword}
onChange={handlePasswordChange}
error={!!errors.new_password}
helperText={errors.new_password}
required
/>
<Box sx={{ width: '100%', mt: 1 }}>
<LinearProgress
variant="determinate"
value={(passwordStrength / 4) * 100}
color={passwordStrengthColor()}
/>
<Typography variant="caption" color="textSecondary">
{['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][passwordStrength]}
</Typography>
</Box>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
/>
</Grid>
<Grid item xs={12}>
<Button type="submit" variant="contained" color="primary">
Change Password
</Button>
</Grid>
</Grid>
</form>
</CardContent>
</Card>
);
};
export default ChangePasswordCard;

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,171 @@
import React, { useState } from 'react';
import { Card, CardContent, Typography, TextField, Button, Alert, Tooltip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
interface OfferSubmissionCardProps {
onOfferSubmit: (
offerAmount: number,
closing_days: number,
contingencies: string,
) => Promise<{ status: number; message?: string }>;
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
listingPrice: number;
existingOffer?: {
document_id: string;
};
}
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
onOfferSubmit,
listingStatus,
listingPrice,
existingOffer,
}) => {
const [offerAmount, setOfferAmount] = useState<string>('');
const [closingDuration, setClosingDuration] = useState<string>('');
const [contingencies, setContingencies] = useState<string>('None');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const getClosingDate = () => {
if (closingDuration) {
const days = parseInt(closingDuration, 10);
if (!isNaN(days)) {
const closingDate = new Date();
closingDate.setDate(closingDate.getDate() + days);
return closingDate.toLocaleDateString();
}
}
return '';
};
const offerPercentage =
offerAmount && listingPrice ? (parseFloat(offerAmount) / listingPrice) * 100 : 0;
if (existingOffer) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Offer Already Submitted
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
You have already submitted an offer for this property.
</Typography>
<Button
variant="contained"
color="primary"
fullWidth
onClick={() => navigate(`/documents?selectedDocument=${existingOffer.document_id}`)}
>
View Offer
</Button>
</CardContent>
</Card>
);
}
if (listingStatus === 'active') {
const handleSubmit = async () => {
const amount = parseFloat(offerAmount);
const closing_days = parseFloat(closingDuration);
if (amount > 0 && closing_days) {
try {
const response = await onOfferSubmit(amount, closing_days, contingencies);
if (response.status === 200 || response.status === 201) {
setSubmitted(true);
setError(null);
setTimeout(() => setSubmitted(false), 5000);
} else {
setError(response.message || 'An unknown error occurred.');
setSubmitted(false);
setTimeout(() => setError(null), 5000);
}
} catch (err: any) {
setError(err.message || 'Failed to submit offer.');
setSubmitted(false);
setTimeout(() => setError(null), 5000);
}
}
};
const isButtonDisabled = !offerAmount || !closingDuration;
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 }}
helperText={
offerPercentage > 0
? `This offer is ${offerPercentage.toFixed(2)}% of the listing price.`
: ''
}
/>
<TextField
fullWidth
label="Closing Duration (days)"
type="number"
value={closingDuration}
onChange={(e) => setClosingDuration(e.target.value)}
sx={{ mb: 2 }}
helperText={closingDuration ? `Estimated closing date: ${getClosingDate()}` : ''}
/>
<Tooltip title="Typical contingencies include financing, inspection, and appraisal.">
<TextField
fullWidth
label="Contingencies"
type="text"
value={contingencies}
onChange={(e) => setContingencies(e.target.value)}
sx={{ mb: 2 }}
/>
</Tooltip>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleSubmit}
disabled={isButtonDisabled}
>
Submit Offer
</Button>
{submitted && (
<Alert severity="success" sx={{ mt: 2 }}>
Your offer of ${offerAmount} has been submitted!
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</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,66 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import { OpenHouseAPI } from 'types';
import { format } from 'date-fns';
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={`${format(new Date(openHouse.listed_date), 'MMM d, yyyy')} at ${format(
new Date(`1970-01-01T${openHouse.start_time}`),
'h a',
)} - ${format(new Date(`1970-01-01T${openHouse.end_time}`), 'h a')}`}
/>
</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,37 @@
// src/components/PropertyOwnerProfile/OpenHouseCard.tsx
import React from 'react';
import { Card, CardContent, Typography, Box } from '@mui/material';
import { OpenHouseAPI } from 'types';
import { format } from 'date-fns';
interface OpenHouseCardProps {
openHouse: OpenHouseAPI;
}
const OpenHouseDialogContent: React.FC<OpenHouseCardProps> = ({ openHouse }) => {
const startTime = new Date(`${openHouse.start_time}`);
const endTime = new Date(`${openHouse.end_time}`);
console.log(endTime);
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" component="div">
Open House at Property: {openHouse.property.address_line_1}
</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="body2" color="textSecondary">
{openHouse.start_time}
</Typography>
<Typography variant="body2" color="textSecondary">
{openHouse.end_time}
</Typography>
</Box>
</CardContent>
</Card>
);
};
export default OpenHouseDialogContent;

View File

@@ -0,0 +1,196 @@
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;
setMessage: (
value: React.SetStateAction<{
type: 'success' | 'error';
text: string;
} | null>,
) => void;
}
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave, setMessage }) => {
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>) => {
if (isEditing) {
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;
});
}
} else {
setMessage({ type: 'error', text: 'Enable editing in the top right' });
setTimeout(() => setMessage(null), 3000);
}
};
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 size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedUser.first_name}
onChange={handleChange}
error={!!formErrors.first_name}
helperText={formErrors.first_name}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedUser.last_name}
onChange={handleChange}
error={!!formErrors.last_name}
helperText={formErrors.last_name}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedUser.email}
onChange={handleChange}
error={!!formErrors.email}
helperText={formErrors.email}
/>
</Grid>
<Grid size={{ 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 size={{ 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,104 @@
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 size={{ 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 size={{ 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,334 @@
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
import ChangePasswordCard from './ChangePasswordCard';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI, OpenHouseAPI } 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';
import AddOpenHouseDialog from './AddOpenHouseDialog';
import OpenHouseDialogContent from './OpenHouseDialogContext';
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate();
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
const [openHouses, setOpenHouses] = useState<OpenHouseAPI[]>([]);
const [openAddOpenHouseDialog, setOpenAddOpenHouseDialog] = useState(false);
const [openHouseErrors, setOpenHouseErrors] = useState<{ [key: string]: string }>({});
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);
}
const { data: openHousesData }: AxiosResponse<OpenHouseAPI[]> = await axiosInstance.get(
'/properties/open-houses/',
);
setOpenHouses(openHousesData);
} 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 handleOpenAddOpenHouseDialog = () => {
setOpenAddOpenHouseDialog(true);
};
const handleCloseAddOpenHouseDialog = () => {
setOpenAddOpenHouseDialog(false);
setOpenHouseErrors({});
};
const handleAddOpenHouse = async (
newOpenHouseData: Omit<OpenHouseAPI, 'id'> & { listed_date: string },
) => {
try {
const { data }: AxiosResponse<OpenHouseAPI> = await axiosInstance.post(
'/properties/open-houses/',
newOpenHouseData,
);
setOpenHouses((prev) => [...prev, data]);
setMessage({ type: 'success', text: 'Open house added successfully!' });
setTimeout(() => setMessage(null), 3000);
setOpenAddOpenHouseDialog(false);
} catch (error) {
console.log(error);
setOpenHouseErrors(error.response.data);
setMessage({ type: 'error', text: 'Error adding open house.' });
setTimeout(() => setMessage(null), 3000);
}
};
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 = async (updatedProperty: PropertiesAPI) => {
try {
const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`,
{
...updatedProperty,
owner: account.id,
},
);
const updatedProperties = properties.map((item) => {
if (item.id === data.id) {
return { ...item, ...data };
}
return item;
});
setProperties(updatedProperties);
setMessage({ type: 'success', text: 'Property has been updated' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleDeleteProperty = async (propertyId: number) => {
try {
await axiosInstance.delete(`/properties/${propertyId}/`);
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
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}
setMessage={setMessage}
/>
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
<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>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{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 size={{ 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}
/>
<Divider sx={{ my: 4 }} />
{properties.length > 0 ? (
<>
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
My Open Houses
</Typography>
<Button variant="contained" color="primary" onClick={handleOpenAddOpenHouseDialog}>
Add Open House
</Button>
</Box>
{openHouses.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
You have no open houses scheduled.
</Typography>
</Paper>
) : (
<Grid container spacing={3}>
{openHouses.map((openHouse) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }} key={openHouse.id}>
{/* You will create a component to display the open house details */}
<OpenHouseDialogContent openHouse={openHouse} />
</Grid>
))}
</Grid>
)}
</>
) : (
<Alert severity="info" sx={{ mb: 2 }}>
Please add a property before you can schedule an open house.
</Alert>
)}
<AddOpenHouseDialog
open={openAddOpenHouseDialog}
onClose={handleCloseAddOpenHouseDialog}
onAddOpenHouse={handleAddOpenHouse}
properties={properties} // Pass the properties to the dialog
errors={openHouseErrors}
/>
</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,188 @@
import { ReactElement, useContext, useEffect, useState } from 'react';
import { UserAPI, VendorAPI } from 'types';
import ChangePasswordCard from './ChangePasswordCard';
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}
/>
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
<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 = !import.meta.env.USE_LIVE_DATA;
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 size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedVendor.user.first_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedVendor.user.last_name}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Email Address"
name="email"
type="email"
value={editedVendor.user.email}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ 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 size={{ xs: 12 }}>
<TextField
fullWidth
label="Business Description"
name="description"
multiline
rows={3}
value={editedVendor.description}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Website URL"
name="website"
value={editedVendor.website || ''}
onChange={handleChange}
disabled={!isEditing}
/>
</Grid>
<Grid size={{ 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 size={{ 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,30 @@
import React, { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
interface LogInNotificationCardProps {}
const LogInNotificationCard: React.FC<LogInNotificationCardProps> = ({}) => {
const navigate = useNavigate();
const goToLogin = async () => {
navigate('/authentication/login');
};
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Want to know more?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Sign in or create an account to view the seller disclosure document and message the owner
with any questions.
</Typography>
<Button variant="contained" fullWidth onClick={goToLogin}>
Log In
</Button>
</CardContent>
</Card>
);
};
export default LogInNotificationCard;

View File

@@ -0,0 +1,478 @@
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}/`);
setEditedProperty((prev) => ({
...prev,
description: response.data.description,
}));
setIsGernerating(false);
};
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 size={{ xs: 12 }}>
{isEditing ? (
<Grid container spacing={2}>
<Grid size={{ xs: 8 }}>
<TextField
fullWidth
label="Address"
name="address"
value={editedProperty.address}
onChange={handleChange}
required
error={!!formErrors.address}
helperText={formErrors.address}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="City"
name="city"
value={editedProperty.city}
onChange={handleChange}
required
error={!!formErrors.city}
helperText={formErrors.city}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="State"
name="state"
value={editedProperty.state}
onChange={handleChange}
required
error={!!formErrors.state}
helperText={formErrors.state}
/>
</Grid>
<Grid size={{ 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 size={{ 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 */}
<Grid size={{ 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>
)}
</Grid>
{/* Stats */}
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" gutterBottom>
Stats:
</Typography>
{isEditing ? (
<Grid container spacing={2}>
<Grid size={{ 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 size={{ 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 size={{ 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 size={{ xs: 6 }}>
<TextField
fullWidth
label="Features (comma-separated)"
name="features"
value={editedProperty.features.join(', ') || ''}
onChange={handleFeaturesChange}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
fullWidth
label="Market Value"
name="market_value"
value={editedProperty.market_value}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
fullWidth
label="Loan Amount"
name="loan_amount"
value={editedProperty.loan_amount}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Loan Term (years)"
name="loan_term"
type="number"
value={editedProperty.loan_term || ''}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ 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 size={{ xs: 12, md: 6 }}>
<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,78 @@
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
isPublic: boolean;
}
const PropertyListItem: React.FC<PropertyListItemProps> = ({
property,
onViewDetails,
isPublic = false,
}) => {
const navigate = useNavigate();
const handleViewDetailsClick = () => {
// Navigate to the full detail page for this property
if (!isPublic) {
navigate(`/property/${property.id}/?search=1`);
} else {
navigate(`/public/${property.id}`);
}
};
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,209 @@
import React, { useState } from 'react';
import {
TextField,
Button,
Box,
Grid,
Paper,
Typography,
Collapse,
IconButton,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
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 [expanded, setExpanded] = useState(false);
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();
};
const handleToggleExpand = () => {
setExpanded(!expanded);
};
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={handleToggleExpand}
>
<Typography variant="h6" gutterBottom>
Property Filters
</Typography>
<IconButton
onClick={handleToggleExpand}
aria-expanded={expanded}
aria-label="toggle filters"
>
<ExpandMoreIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} />
</IconButton>
</Box>
<Collapse in={expanded}>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Address Keyword"
name="address"
value={filters.address}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="City"
name="city"
value={filters.city}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="State"
name="state"
value={filters.state}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Zip Code"
name="zipCode"
value={filters.zipCode}
onChange={handleChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Min Sq Ft"
name="minSqFt"
type="number"
value={filters.minSqFt}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Max Sq Ft"
name="maxSqFt"
type="number"
value={filters.maxSqFt}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Min Bedrooms"
name="minBedrooms"
type="number"
value={filters.minBedrooms}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Max Bedrooms"
name="maxBedrooms"
type="number"
value={filters.maxBedrooms}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Min Bathrooms"
name="minBathrooms"
type="number"
value={filters.minBathrooms}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
fullWidth
label="Max Bathrooms"
name="maxBathrooms"
type="number"
value={filters.maxBathrooms}
onChange={handleNumericChange}
/>
</Grid>
<Grid size={{ 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>
</Collapse>
</Paper>
);
};
export default PropertySearchFilters;

View File

@@ -0,0 +1,155 @@
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, SavedPropertiesAPI } from 'types';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
const getIcon = (
savedProperty: SavedPropertiesAPI | null,
): typeof FavoriteBorderIcon | typeof FavoriteIcon => {
return savedProperty ? FavoriteIcon : FavoriteBorderIcon;
};
interface PropertyStatusCardProps {
property: PropertiesAPI;
isOwner: boolean;
onStatusChange?: (string) => void;
onSavedPropertySave?: () => void;
savedProperty: SavedPropertiesAPI | null;
sellerDisclosureExists: boolean;
}
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
property,
isOwner,
onStatusChange,
onSavedPropertySave,
savedProperty,
sellerDisclosureExists,
}) => {
const handleStatusChange = (e) => {
const newStatus = e.target.value;
if (newStatus === 'active' && !sellerDisclosureExists) {
alert('A seller disclosure document is required before putting the property on the market.');
return;
}
onStatusChange(newStatus);
};
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.split('T')[0]);
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={handleStatusChange}
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="primary" sx={{ mr: 1 }} />
<Typography variant="body1">{property.views} Views</Typography>
</Box>
<Box display="flex" alignItems="center">
{isOwner ? (
<FavoriteIcon color="primary" sx={{ mr: 1 }} />
) : (
<Box
component={getIcon(savedProperty)}
color="primary"
sx={{ mr: 1 }}
onClick={onSavedPropertySave}
/>
)}
<Typography variant="body1">{property.saves} Saves</Typography>
</Box>
{timeOnMarketString && (
<Box display="flex" alignItems="center">
<AccessTimeIcon color="primary" 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,48 @@
import React from 'react';
import { Modal, Box } from '@mui/material';
import SellerDisclosureDisplay from '../Documents/SellerDisclosureDisplay';
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
interface SellerDisclosureDialogProps {
open: boolean;
onClose: () => void;
disclosureData: SellerDisclosureData;
property: Property;
}
const style = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%', // Make it wider to accommodate the display
maxWidth: '900px',
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
overflowY: 'auto',
maxHeight: '90vh',
};
const SellerDisclosureDialog: React.FC<SellerDisclosureDialogProps> = ({
open,
onClose,
disclosureData,
property,
}) => {
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="seller-disclosure-modal-title"
aria-describedby="seller-disclosure-modal-description"
>
<Box sx={style}>
<SellerDisclosureDisplay disclosureData={disclosureData} property={property} />
</Box>
</Modal>
);
};
export default SellerDisclosureDialog;

View File

@@ -0,0 +1,94 @@
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
import { AccountContext } from 'contexts/AccountContext';
import SellerDisclosureDialog from './SellerDisclosureDialog';
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
interface SellerInformationCardProps {
sellerDisclosureExists: boolean;
onSendMessage: () => void;
disclosureData?: SellerDisclosureData;
property?: Property;
}
const SellerInformationCard: React.FC<SellerInformationCardProps> = ({
sellerDisclosureExists,
onSendMessage,
disclosureData,
property,
}) => {
const { account, accountLoading } = useContext(AccountContext);
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const handleOpen = () => {
setOpen(true);
};
const handleClose = () => setOpen(false);
if (accountLoading) {
return null; // Or a loading indicator
}
if (!account) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Seller Disclosure & Questions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create an account to view the seller disclosure document and message the owner with any
questions.
</Typography>
<Button variant="contained" onClick={() => navigate('/signup')}>
Create Account
</Button>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Seller Disclosure & Questions
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<Button
variant="outlined"
fullWidth
disabled={!sellerDisclosureExists}
onClick={handleOpen}
>
{sellerDisclosureExists
? 'View Seller Disclosure'
: 'Seller Disclosure Not Available'}
</Button>
</Grid>
<Grid item xs={12}>
<Button variant="contained" fullWidth onClick={onSendMessage}>
Ask a Question
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{disclosureData && property && (
<SellerDisclosureDialog
open={open}
onClose={handleClose}
disclosureData={disclosureData}
property={property}
/>
)}
</>
);
};
export default SellerInformationCard;

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,83 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Autocomplete,
} from '@mui/material';
import { ReactElement, useState } from 'react';
import { SupportCaseApi } from 'types';
type CreateSupportCaseDialogProps = {
showDialog: boolean;
closeDialog: () => void;
createSupportCase: (supportCase: Omit<SupportCaseApi, 'id' | 'status' | 'messages' | 'created_at' | 'updated_at'>) => void;
};
const categoryOptions = ['question', 'bug', 'other'];
const CreateSupportCaseDialogContent = ({
showDialog,
closeDialog,
createSupportCase,
}: CreateSupportCaseDialogProps): ReactElement => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<string | null>(null);
const handleCreate = () => {
if (title && description && category) {
createSupportCase({ title, description, category });
}
};
return (
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
<DialogTitle>Create a New Support Case</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="title"
label="Title"
type="text"
fullWidth
variant="outlined"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<TextField
margin="dense"
id="description"
label="Description"
type="text"
fullWidth
multiline
rows={4}
variant="outlined"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Autocomplete
options={categoryOptions}
onChange={(event, newValue) => {
setCategory(newValue);
}}
renderInput={(params) => (
<TextField {...params} label="Category" variant="outlined" />
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button onClick={handleCreate} color="primary" disabled={!title || !description || !category}>
Create
</Button>
</DialogActions>
</Dialog>
);
};
export default CreateSupportCaseDialogContent;

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)}
disabled={category.numVendors == 0}
>
View Vendors
</Button>
</Box>
</Card>
);
};
export default VendorCategoryCard;

View File

@@ -0,0 +1,166 @@
// 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 = async () => {
// First see if there is one already
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
`/conversations/?vendor=${vendor.id}`,
);
if (data.length === 0) {
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.post(
`/conversations/`,
{
property_owner: account?.id,
vendor: vendor.id,
},
);
navigate(`/conversations/?selectedConversation=${data[0].id}`);
} else {
navigate(`/conversations/?selectedConversation=${data[0].id}`);
}
};
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 size={{ 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 size={{ 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,211 @@
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();
}
const wsUrl = new URL(import.meta.env.VITE_API_URL || 'ws://127.0.0.1:8010/ws/');
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
ws.current = new WebSocket(
`${wsUrl.origin}/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,56 @@
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: 'Search',
path: '/property-search',
icon: 'ph:magnifying-glass',
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,
},
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
];
export default attorneyNavItems;

View File

@@ -0,0 +1,112 @@
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: '/upgrade',
icon: 'ph:student',
active: false,
collapsible: false,
},
{
title: 'Vendors',
path: '/upgrade',
icon: 'ph:storefront',
active: false,
collapsible: false,
},
{
title: 'Messages',
path: '',
icon: 'ph:chat-circle-dots',
active: false,
collapsible: true,
sublist: [
{
title: 'Documents',
path: 'documents',
icon: 'ph:folder',
active: false,
collapsible: false,
},
{
title: 'Conversations',
path: 'upgrade',
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,
},
],
},
{
title: 'Support',
path: '/support',
icon: 'ph:question',
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,111 +1,120 @@
export interface NavItem {
title: string;
path: string;
icon?: string;
active: boolean;
collapsible: boolean;
sublist?: NavItem[];
}
const navItems: NavItem[] = [
{
title: 'Home',
path: '/',
icon: 'ion:home-sharp',
active: true,
collapsible: false,
sublist: [
{
title: 'Dashboard',
path: '/',
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',
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',
active: true,
collapsible: false,
},
{
title: 'Education',
path: '/education',
icon: 'ph:student',
active: true,
collapsible: false,
},
{
title: 'Vendors',
path: '/vendors',
icon: 'ph:toolbox',
active: true,
collapsible: false,
},
];
export default navItems;
import { NavItem } from 'types';
const navItems: NavItem[] = [
{
title: 'Home',
path: '/dashboard',
icon: 'ion:home-sharp',
active: true,
collapsible: false,
sublist: [
{
title: 'Dashboard',
path: '/dasboard',
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: true,
collapsible: false,
},
{
title: 'Vendors',
path: '/vendors',
icon: 'ph:storefront',
active: true,
collapsible: false,
},
{
title: 'Messages',
path: '',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: true,
sublist: [
{
title: 'Bids',
path: 'bids',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Conversations',
path: 'conversations',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Documents',
path: 'documents',
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,
},
],
},
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
];
export default navItems;

View File

@@ -0,0 +1,13 @@
import { NavItem } from 'types';
const publicNavItems: NavItem[] = [
{
title: 'Search',
path: '/property-search',
icon: 'ph:magnifying-glass',
active: true,
collapsible: false,
},
];
export default publicNavItems;

View File

@@ -0,0 +1,56 @@
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: 'Search',
path: '/property-search',
icon: 'ph:magnifying-glass',
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,
},
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
];
export default vendorNavItems;

View File

@@ -1,26 +1,26 @@
import { Link, Stack, Typography } from '@mui/material';
const Footer = () => {
return (
<Stack
direction="row"
justifyContent={{ xs: 'center', md: 'flex-end' }}
ml={{ xs: 3.75, lg: 34.75 }}
mr={3.75}
my={3.75}
>
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
<Link
href="https://ditchtheagent.com/"
target="_blank"
rel="noopener"
sx={{ color: 'text.primary', '&:hover': { color: 'primary.main' } }}
>
Ditch The Agent
</Link>
</Typography>
</Stack>
);
};
export default Footer;
import { Link, Stack, Typography } from '@mui/material';
const Footer = () => {
return (
<Stack
direction="row"
justifyContent={{ xs: 'center' }}
ml={{ xs: 3.75, lg: 34.75 }}
mr={3.75}
my={3.75}
>
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
<Link
href="https://ditchtheagent.com/"
target="_blank"
rel="noopener"
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
>
Ditch The Agent
</Link>
</Typography>
</Stack>
);
};
export default Footer;

View File

@@ -1,149 +1,153 @@
import { ReactElement, useState } from 'react';
import {
Collapse,
LinkTypeMap,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import IconifyIcon from 'components/base/IconifyIcon';
import { useLocation } from 'react-router-dom';
import { NavItem } from 'data/nav-items';
interface NavItemProps {
navItem: NavItem;
Link: OverridableComponent<LinkTypeMap>;
}
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
const { pathname } = useLocation();
const [checked, setChecked] = useState(false);
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
const handleNestedChecked = (index: any, value: boolean) => {
const updatedBooleanArray = [...nestedChecked];
updatedBooleanArray[index] = value;
setNestedChecked(updatedBooleanArray);
};
return (
<ListItem
sx={{
my: 1.25,
borderRadius: 2,
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
'&:hover': {
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
opacity: 1.5,
},
}}
>
{navItem.collapsible ? (
<>
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
<ListItemIcon>
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
</ListItemIcon>
<ListItemText>{navItem.title}</ListItemText>
<ListItemIcon>
{navItem.collapsible &&
(checked ? (
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
) : (
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
))}
</ListItemIcon>
</ListItemButton>
<Collapse in={checked}>
<List>
{navItem.sublist?.map((subListItem: any, idx: number) => (
<ListItem
key={idx}
sx={{
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
'&:hover': {
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
opacity: 1.5,
},
}}
>
{subListItem.collapsible ? (
<>
<ListItemButton
LinkComponent={Link}
onClick={() => {
handleNestedChecked(idx, !nestedChecked[idx]);
}}
>
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
<ListItemIcon>
{subListItem.collapsible &&
(nestedChecked[idx] ? (
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
) : (
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
))}
</ListItemIcon>
</ListItemButton>
<Collapse in={nestedChecked[idx]}>
<List>
{subListItem?.sublist?.map(
(nestedSubListItem: any, nestedIdx: number) => (
<ListItem key={nestedIdx}>
<ListItemButton
LinkComponent={Link}
href={
navItem.path !== '/'
? navItem.path +
'/' +
subListItem.path +
'/' +
nestedSubListItem.path
: nestedSubListItem.path
}
>
<ListItemText sx={{ ml: 5 }}>
{nestedSubListItem.title}
</ListItemText>
</ListItemButton>
</ListItem>
),
)}
</List>
</Collapse>
</>
) : (
<ListItemButton
LinkComponent={Link}
href={navItem.path + '/' + subListItem.path}
>
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
</ListItemButton>
)}
</ListItem>
))}
</List>
</Collapse>
</>
) : (
<ListItemButton
LinkComponent={Link}
href={navItem.path}
sx={{ opacity: navItem.active ? 1 : 0.6 }}
>
<ListItemIcon>
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
</ListItemIcon>
<ListItemText>{navItem.title}</ListItemText>
</ListItemButton>
)}
</ListItem>
);
};
export default NavButton;
import { ReactElement, useState } from 'react';
import {
Collapse,
LinkTypeMap,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import IconifyIcon from 'components/base/IconifyIcon';
import { useLocation } from 'react-router-dom';
import { NavItem } from 'data/nav-items';
interface NavItemProps {
navItem: NavItem;
Link: OverridableComponent<LinkTypeMap>;
}
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
const { pathname } = useLocation();
const [checked, setChecked] = useState(false);
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
const handleNestedChecked = (index: any, value: boolean) => {
const updatedBooleanArray = [...nestedChecked];
updatedBooleanArray[index] = value;
setNestedChecked(updatedBooleanArray);
};
const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
return (
<ListItem
sx={{
my: 1.25,
borderRadius: 2,
backgroundColor: { backgroundColor },
color: { color },
'&:hover': {
backgroundColor: { hoverBackgroundColor },
opacity: 1.5,
},
}}
>
{navItem.collapsible ? (
<>
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
<ListItemIcon>
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
</ListItemIcon>
<ListItemText>{navItem.title}</ListItemText>
<ListItemIcon>
{navItem.collapsible &&
(checked ? (
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
) : (
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
))}
</ListItemIcon>
</ListItemButton>
<Collapse in={checked}>
<List>
{navItem.sublist?.map((subListItem: any, idx: number) => (
<ListItem
key={idx}
sx={{
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
'&:hover': {
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
opacity: 1.5,
},
}}
>
{subListItem.collapsible ? (
<>
<ListItemButton
LinkComponent={Link}
onClick={() => {
handleNestedChecked(idx, !nestedChecked[idx]);
}}
>
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
<ListItemIcon>
{subListItem.collapsible &&
(nestedChecked[idx] ? (
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
) : (
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
))}
</ListItemIcon>
</ListItemButton>
<Collapse in={nestedChecked[idx]}>
<List>
{subListItem?.sublist?.map(
(nestedSubListItem: any, nestedIdx: number) => (
<ListItem key={nestedIdx}>
<ListItemButton
LinkComponent={Link}
href={
navItem.path !== '/'
? navItem.path +
'/' +
subListItem.path +
'/' +
nestedSubListItem.path
: nestedSubListItem.path
}
>
<ListItemText sx={{ ml: 5 }}>
{nestedSubListItem.title}
</ListItemText>
</ListItemButton>
</ListItem>
),
)}
</List>
</Collapse>
</>
) : (
<ListItemButton
LinkComponent={Link}
href={navItem.path + '/' + subListItem.path}
>
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
</ListItemButton>
)}
</ListItem>
))}
</List>
</Collapse>
</>
) : (
<ListItemButton
LinkComponent={Link}
href={navItem.path}
sx={{ opacity: navItem.active ? 1 : 0.6 }}
>
<ListItemIcon>
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
</ListItemIcon>
<ListItemText>{navItem.title}</ListItemText>
</ListItemButton>
)}
</ListItem>
);
};
export default NavButton;

Some files were not shown because too many files have changed in this diff Show More