big update
This commit is contained in:
1
ditch-the-agent/.env
Normal file
1
ditch-the-agent/.env
Normal file
@@ -0,0 +1 @@
|
||||
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
|
||||
842
ditch-the-agent/package-lock.json
generated
842
ditch-the-agent/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/x-data-grid": "^7.2.0",
|
||||
"@mui/x-data-grid-generator": "^7.2.0",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@vis.gl/react-google-maps": "^1.5.4",
|
||||
"axios": "^1.10.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
@@ -39,6 +41,7 @@
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.2.2",
|
||||
|
||||
@@ -1,96 +1,105 @@
|
||||
import axios from "axios"
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
||||
//const baseURL = 'https://backend.ditchtheagent.com/api/';
|
||||
|
||||
export const axiosRealEstateApi = axios.create({
|
||||
baseURL: 'https://api.realestateapi.com/v2/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70',
|
||||
'X-User-Id': 'UniqueUserIdentifier',
|
||||
},
|
||||
});
|
||||
|
||||
export const axiosWalkScoreApiInstance = axios.create({
|
||||
baseURL: 'https://api.walkscore.com/',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"Authorization": 'JWT ' + localStorage.getItem('access_token'),
|
||||
'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);
|
||||
},
|
||||
);
|
||||
29
ditch-the-agent/src/components/CategoryGridTemplate.tsx
Normal file
29
ditch-the-agent/src/components/CategoryGridTemplate.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/templates/CategoryGridTemplate.tsx
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import { GenericCategory } from 'types';
|
||||
|
||||
|
||||
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
|
||||
categories: TCategory[];
|
||||
onSelectCategory: (categoryId: string) => void;
|
||||
renderCategoryCard: (category: TCategory, onSelect: (categoryId: string) => void) => ReactNode;
|
||||
}
|
||||
|
||||
function CategoryGridTemplate<TCategory extends GenericCategory>({
|
||||
categories,
|
||||
onSelectCategory,
|
||||
renderCategoryCard,
|
||||
}: CategoryGridTemplateProps<TCategory>) {
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
{categories.map((category) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={category.id}>
|
||||
{renderCategoryCard(category, onSelectCategory)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryGridTemplate;
|
||||
67
ditch-the-agent/src/components/DasboardTemplate.tsx
Normal file
67
ditch-the-agent/src/components/DasboardTemplate.tsx
Normal 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;
|
||||
@@ -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,37 +392,35 @@ 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} />}
|
||||
|
||||
@@ -361,11 +430,8 @@ const FloatingChatButton = (): ReactElement =>
|
||||
toggleMinimize={toggleMinimize}
|
||||
closeChat={closeChat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingChatButton;
|
||||
78
ditch-the-agent/src/components/ItemListDetailTemplate.tsx
Normal file
78
ditch-the-agent/src/components/ItemListDetailTemplate.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// src/templates/ItemListDetailTemplate.tsx
|
||||
import React, { useState, useEffect, ReactNode } from 'react';
|
||||
import { Box, Grid, List, ListItem, ListItemText, Typography, Paper, Button, Stack, IconButton } from '@mui/material';
|
||||
import { GenericCategory, GenericItem } from 'types';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
interface ItemListDetailTemplateProps<TCategory extends GenericCategory, TItem extends GenericItem> {
|
||||
category: TCategory;
|
||||
items: TItem[];
|
||||
onBack: () => void;
|
||||
renderListItem: (item: TItem, isSelected: boolean, onSelect: (itemId: string) => void) => ReactNode;
|
||||
renderItemDetail: (item: TItem) => ReactNode;
|
||||
}
|
||||
|
||||
function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends GenericItem>({
|
||||
category,
|
||||
items,
|
||||
onBack,
|
||||
renderListItem,
|
||||
renderItemDetail,
|
||||
}: ItemListDetailTemplateProps<TCategory, TItem>) {
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
|
||||
|
||||
// Default to the first item in the list
|
||||
let temp = null
|
||||
useEffect(() => {
|
||||
|
||||
if (items.length > 0) {
|
||||
temp = items[0].id
|
||||
setSelectedItemId(items[0].id);
|
||||
} else {
|
||||
|
||||
setSelectedItemId(null);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
|
||||
|
||||
console.log(selectedItemId, selectedItem)
|
||||
|
||||
const handleItemSelect = (itemId: string) => {
|
||||
setSelectedItemId(itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
|
||||
<Stack direction="row">
|
||||
<IconButton size='small' color="inherit" onClick={onBack} sx={{mr:1}}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" gutterBottom>{category.name} List</Typography>
|
||||
</Stack>
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<Box key={item.id}>
|
||||
{renderListItem(item, selectedItem?.id === item.id, handleItemSelect)}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
{selectedItem ? (
|
||||
renderItemDetail(selectedItem)
|
||||
) : (
|
||||
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">Select an item to view details</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemListDetailTemplate;
|
||||
21
ditch-the-agent/src/components/base/FormattedListingText.tsx
Normal file
21
ditch-the-agent/src/components/base/FormattedListingText.tsx
Normal 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;
|
||||
58
ditch-the-agent/src/components/base/GeocodeComponent.tsx
Normal file
58
ditch-the-agent/src/components/base/GeocodeComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
ditch-the-agent/src/components/base/LoadingSkeleton.tsx
Normal file
25
ditch-the-agent/src/components/base/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
|
||||
|
||||
import { ReactElement} from 'react';
|
||||
|
||||
const LoadingSkeleton = (): ReactElement => {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
</Container>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default LoadingSkeleton;
|
||||
77
ditch-the-agent/src/components/base/MapComponent.tsx
Normal file
77
ditch-the-agent/src/components/base/MapComponent.tsx
Normal 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;
|
||||
165
ditch-the-agent/src/components/base/MapSearchComponent.tsx
Normal file
165
ditch-the-agent/src/components/base/MapSearchComponent.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Container, Typography, Grid, Card, CardContent } from '@mui/material';
|
||||
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import { BidAPI } from 'types';
|
||||
import { VendorBidCard } from './VendorBidCard';
|
||||
|
||||
const VendorBidsPage: React.FC = () => {
|
||||
const [bids, setBids] = useState<BidAPI[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBids();
|
||||
}, []);
|
||||
|
||||
const fetchBids = async () => {
|
||||
try {
|
||||
// Endpoint to get all bids a vendor can see
|
||||
const { data: bidData }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||
setBids(bidData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bids', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" gutterBottom color="background.paper">
|
||||
Available Bids
|
||||
</Typography>
|
||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||
{bids.map((bid) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={bid.id}>
|
||||
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorBidsPage;
|
||||
@@ -0,0 +1,175 @@
|
||||
// src/pages/AttorneyDashboardPage.tsx
|
||||
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Button,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import EventIcon from '@mui/icons-material/Event';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { DashboardProps } from 'pages/home/Dashboard';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AttorneyAPI } from 'types';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import DashboardLoading from './DashboardLoading';
|
||||
import DashboardErrorPage from './DashboardErrorPage';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
// Mock Data for the Attorney Dashboard
|
||||
interface AttorneyCase {
|
||||
id: number;
|
||||
title: string;
|
||||
status: 'active' | 'closed' | 'urgent';
|
||||
deadline: string;
|
||||
}
|
||||
|
||||
const mockAttorneyCases: AttorneyCase[] = [
|
||||
{ id: 1, title: 'Closing for 123 Main St', status: 'urgent', deadline: 'August 15, 2025' },
|
||||
{ id: 2, title: 'Contract Review - 456 Oak Ave', status: 'active', deadline: 'August 20, 2025' },
|
||||
{ id: 3, title: 'Title Search - 789 Pine Ln', status: 'active', deadline: 'September 1, 2025' },
|
||||
];
|
||||
|
||||
const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
const [attorney, setAttorney] = useState<AttorneyAPI | null>(null);
|
||||
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const fetchAttorney = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<AttorneyAPI[]> = await axiosInstance.get('/attorney/');
|
||||
if (data.length > 0) {
|
||||
setAttorney(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchAttorney();
|
||||
}, []);
|
||||
if (loadingData) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
if (attorney === null) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
|
||||
Attorney Dashboard
|
||||
</Typography>
|
||||
{!account.profile_created && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Please set up your <a href="/profile">profile</a>
|
||||
</Alert>
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{/* Active Cases Card */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<FolderIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Active Cases
|
||||
</Typography>
|
||||
</Box>
|
||||
<List>
|
||||
{mockAttorneyCases.map((c) => (
|
||||
<React.Fragment key={c.id}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{c.status === 'urgent' ? (
|
||||
<WarningIcon color="warning" />
|
||||
) : (
|
||||
<CheckCircleIcon color="success" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={c.title} secondary={`Deadline: ${c.deadline}`} />
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
<Button fullWidth sx={{ mt: 2 }}>
|
||||
View All Cases
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Upcoming Deadlines Card */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<EventIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Upcoming Deadlines
|
||||
</Typography>
|
||||
</Box>
|
||||
<List>
|
||||
{mockAttorneyCases
|
||||
.filter((c) => c.status === 'urgent')
|
||||
.map((c) => (
|
||||
<ListItem key={c.id}>
|
||||
<ListItemIcon>
|
||||
<WarningIcon color="warning" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={c.title} secondary={`Due by: ${c.deadline}`} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button fullWidth sx={{ mt: 2 }}>
|
||||
View Calendar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Documents to Review Card */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<DescriptionIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Documents Requiring Action
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Contract for 123 Main St"
|
||||
secondary="Needs your signature"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Title Report for 456 Oak Ave"
|
||||
secondary="Awaiting your review"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttorneyDashboard;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,302 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { PropertiesAPI, UserAPI } from 'types';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Divider,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import { ProperyInfoCards } from '../Property/PropertyInfo';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import HouseIcon from '@mui/icons-material/House';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
import { EducationInfoCards } from '../Education/EducationInfo';
|
||||
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import { GridColDef } from '@mui/x-data-grid';
|
||||
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
||||
import { DashboardProps } from 'pages/home/Dashboard';
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||
const [numBids, setNumBids] = useState<Number>(0);
|
||||
const [numOffers, setNumOffers] = useState<Number>(0);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const fetchProperties = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||
if (data.length > 0) {
|
||||
setProperties(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
const fetchOffers = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/offers/');
|
||||
if (data.length > 0) {
|
||||
setNumOffers(data.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBids = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/bids/');
|
||||
if (data.length > 0) {
|
||||
setNumBids(data.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
fetchProperties();
|
||||
fetchOffers();
|
||||
fetchBids();
|
||||
}, []);
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
console.log('handle save. IMPLEMENT ME');
|
||||
};
|
||||
|
||||
const handleDeleteProperty = (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
};
|
||||
|
||||
const documentColumns: GridColDef[] = [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
headerName: 'Action',
|
||||
flex: 0,
|
||||
renderCell: (params: GridRenderCellParams<Row, number>) => {
|
||||
return (
|
||||
<Button variant="contained" component="label">
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DocumentRows = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Offer',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Disclousre Form',
|
||||
},
|
||||
];
|
||||
|
||||
const numViews = properties.reduce((accum, currProperty) => {
|
||||
return accum + currProperty.views;
|
||||
}, 0);
|
||||
const numSaves = properties.reduce((accum, currProperty) => {
|
||||
return accum + currProperty.saves;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
component="main"
|
||||
columns={12}
|
||||
spacing={3.75}
|
||||
flexGrow={1}
|
||||
pt={4.375}
|
||||
pr={1.875}
|
||||
pb={0}
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
pl: { xs: 3.75, lg: 0 },
|
||||
}}
|
||||
>
|
||||
{/* quick states */}
|
||||
{!account.profile_created && (
|
||||
<Grid xs={12} key="profile-setup">
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Please set up your <a href="/profile">profile</a>
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="active-listing-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/profile')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<HouseIcon />
|
||||
<Typography variant="button">{properties.length}</Typography>
|
||||
<Typography variant="caption">Active Listings</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-views-card">
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<VisibilityIcon />
|
||||
<Typography>{numViews}</Typography>
|
||||
<Typography variant="caption">Total Views</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="total-saves-card">
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<FavoriteIcon />
|
||||
<Typography>{numSaves}</Typography>
|
||||
<Typography variant="caption">Total Saves</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-offers-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/offers')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<RequestQuoteIcon />
|
||||
<Typography variant="h6">{numOffers}</Typography>
|
||||
<Typography variant="caption">Total Offers</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-bids-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/bids')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<RequestQuoteIcon />
|
||||
<Typography variant="h6">{numBids}</Typography>
|
||||
<Typography variant="caption">Total Bids</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
{/* Properties */}
|
||||
{properties.map((item) => (
|
||||
<Grid xs={12} key={item.id}>
|
||||
<PropertyDetailCard
|
||||
property={item}
|
||||
isPublicPage={false}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={true}
|
||||
onDelete={handleDeleteProperty}
|
||||
/>
|
||||
{/* <ProperyInfoCards property={item} /> */}
|
||||
</Grid>
|
||||
))}
|
||||
<Grid xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
<Grid xs={12} md={6}>
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Documents Requiring Attention</Typography>
|
||||
<Typography variant="caption">something</Typography>
|
||||
</Stack>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={6}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Video Progress</Typography>
|
||||
<Typography variant="caption">
|
||||
Complete our FSBO training to maximize your sale potential
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<CardContent>
|
||||
<EducationInfoCards />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Upgrade your account</Typography>
|
||||
<Typography variant="caption">
|
||||
Unlock premium features to get more features and sell faster
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<CardActionArea>
|
||||
<Button variant="contained" component="label">
|
||||
Upgrade
|
||||
</Button>
|
||||
<Button>Learn More</Button>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* <Grid xs={12} md={4}>
|
||||
<NotificationInfoCard />
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={8}>
|
||||
<EducationInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<SaleInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<Revenue />
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<WebsiteVisitors />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={8}>
|
||||
<TopSellingProduct />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={4}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
|
||||
gap={3.75}
|
||||
height={1}
|
||||
width={1}
|
||||
>
|
||||
<NewCustomers />
|
||||
<BuyersProfile />
|
||||
</Stack>
|
||||
</Grid> */}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyOwnerDashboard;
|
||||
@@ -0,0 +1,169 @@
|
||||
// src/pages/RealEstateAgentDashboardPage.tsx
|
||||
|
||||
import React, { ReactElement } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import GavelIcon from '@mui/icons-material/Gavel';
|
||||
import EventAvailableIcon from '@mui/icons-material/EventAvailable';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import { DashboardProps } from 'pages/home/Dashboard';
|
||||
|
||||
// Mock Data for the Real Estate Agent Dashboard
|
||||
interface AgentListing {
|
||||
id: number;
|
||||
address: string;
|
||||
status: 'active' | 'pending';
|
||||
offers: number;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface AgentOffer {
|
||||
id: number;
|
||||
property_address: string;
|
||||
offer_amount: number;
|
||||
offer_date: string;
|
||||
}
|
||||
|
||||
const mockAgentListings: AgentListing[] = [
|
||||
{ id: 1, address: '123 Main St, Anytown', status: 'active', offers: 3, views: 250 },
|
||||
{ id: 2, address: '456 Oak Ave, Anytown', status: 'pending', offers: 1, views: 180 },
|
||||
{ id: 3, address: '789 Pine Ln, Othertown', status: 'active', offers: 0, views: 50 },
|
||||
];
|
||||
|
||||
const mockAgentOffers: AgentOffer[] = [
|
||||
{
|
||||
id: 1,
|
||||
property_address: '123 Main St, Anytown',
|
||||
offer_amount: 510000,
|
||||
offer_date: 'August 5, 2025',
|
||||
},
|
||||
];
|
||||
|
||||
const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Agent Dashboard
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{/* Listings Summary Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<HomeIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
My Listings
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{mockAgentListings.length}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1">Total Active Listings</Typography>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body1">
|
||||
Total Offers: {mockAgentListings.reduce((sum, l) => sum + l.offers, 0)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* New Offers Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
New Offers
|
||||
</Typography>
|
||||
</Box>
|
||||
{mockAgentOffers.length > 0 ? (
|
||||
<List dense>
|
||||
{mockAgentOffers.map((offer) => (
|
||||
<ListItem key={offer.id}>
|
||||
<ListItemText
|
||||
primary={`$${offer.offer_amount.toLocaleString()}`}
|
||||
secondary={`On ${offer.property_address}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography variant="body1">No new offers at this time.</Typography>
|
||||
)}
|
||||
<Button fullWidth sx={{ mt: 2 }}>
|
||||
View All Offers
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Upcoming Showings Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<EventAvailableIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Upcoming Appointments
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<PersonAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Showing for John Doe"
|
||||
secondary="123 Main St - 2:00 PM today"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<PersonAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Open House" secondary="789 Pine Ln - Sunday at 1:00 PM" />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Button fullWidth sx={{ mt: 2 }}>
|
||||
View My Calendar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Example of other cards */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Listing Performance
|
||||
</Typography>
|
||||
{/* A chart or a list of top-performing listings could go here */}
|
||||
<Typography variant="body1">
|
||||
123 Main St is your top-performing listing with 250 views.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealEstateAgentDashboard;
|
||||
@@ -0,0 +1,292 @@
|
||||
// src/pages/VendorDashboardPage.tsx
|
||||
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Chip,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import GavelIcon from '@mui/icons-material/Gavel';
|
||||
import ChatBubbleIcon from '@mui/icons-material/ChatBubble';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import PaidIcon from '@mui/icons-material/Paid';
|
||||
import { DashboardProps } from 'pages/home/Dashboard';
|
||||
import { BidAPI, ConverationAPI, VendorAPI, VendorItem } from 'types';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import DashboardLoading from './DashboardLoading';
|
||||
import DashboardErrorPage from './DashboardErrorPage';
|
||||
import VendorDetail from '../Vendor/VendorDetail';
|
||||
|
||||
// Mock Data for the Vendor Dashboard
|
||||
interface VendorDashboardData {
|
||||
views: {
|
||||
total: number;
|
||||
last_30_days: number;
|
||||
};
|
||||
bids: {
|
||||
total: number;
|
||||
responded_to: number;
|
||||
new_bids: number;
|
||||
selected_for: number;
|
||||
};
|
||||
conversations: {
|
||||
unread: number;
|
||||
};
|
||||
}
|
||||
|
||||
const mockVendorData: VendorDashboardData = {
|
||||
views: {
|
||||
total: 5432,
|
||||
last_30_days: 215,
|
||||
},
|
||||
bids: {
|
||||
total: 45,
|
||||
responded_to: 38,
|
||||
new_bids: 7,
|
||||
selected_for: 12,
|
||||
},
|
||||
conversations: {
|
||||
unread: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
const [vendor, setVendor] = useState<VendorAPI | null>(null);
|
||||
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||
// bid data
|
||||
const [numBids, setNumBids] = useState<Number>(0);
|
||||
const [numBidResponses, setNumBidResponses] = useState<Number>(0);
|
||||
const [numBidsSelected, setNumBidsSelected] = useState<Number>(0);
|
||||
const [newBids, setNewBids] = useState<Number>(0);
|
||||
const [numConverstaions, setNumConversations] = useState<Number>(0);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const fetchVendor = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
|
||||
if (data.length > 0) {
|
||||
setVendor(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
const fetchBids = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||
if (data.length > 0) {
|
||||
setNumBids(data.length);
|
||||
let numBidResponsesFound: number = 0;
|
||||
let numBidsSelectedFound: number = 0;
|
||||
let newBidsFound: number = 0;
|
||||
data.map((item) => {
|
||||
let foundNewBid: boolean = true;
|
||||
item.responses.map((response) => {
|
||||
if (response.vendor.user.id === account.id) {
|
||||
numBidResponsesFound = numBidResponsesFound + 1;
|
||||
foundNewBid = false;
|
||||
}
|
||||
if (response.vendor.user.id === account.id && response.status === 'selected') {
|
||||
numBidsSelectedFound = numBidsSelectedFound + 1;
|
||||
}
|
||||
});
|
||||
newBidsFound = foundNewBid ? newBidsFound + 1 : newBidsFound;
|
||||
});
|
||||
|
||||
setNumBidResponses(numBidResponsesFound);
|
||||
setNumBidsSelected(numBidsSelectedFound);
|
||||
setNewBids(newBidsFound);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<ConverationAPI[]> =
|
||||
await axiosInstance.get('/conversations/');
|
||||
if (data.length > 0) {
|
||||
setNumConversations(data.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchVendor();
|
||||
fetchBids();
|
||||
fetchConversations();
|
||||
}, []);
|
||||
if (loadingData) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
if (vendor === null) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
|
||||
const vendorItem: VendorItem = {
|
||||
contactPerson: vendor.user.first_name + ' ' + vendor.user.last_name,
|
||||
name: vendor.business_name,
|
||||
description: vendor.description,
|
||||
phone: vendor.phone_number,
|
||||
email: vendor.user.email,
|
||||
address: vendor.address,
|
||||
vendorImageUrl: '',
|
||||
rating: 5,
|
||||
servicesOffered: vendor.services,
|
||||
serviceAreas: vendor.service_areas,
|
||||
categoryId: vendor.business_type,
|
||||
latitude: Number(vendor.latitude),
|
||||
longitude: Number(vendor.longitude),
|
||||
views: vendor.views,
|
||||
};
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
|
||||
Vendor Dashboard
|
||||
</Typography>
|
||||
{!account.profile_created && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Please set up your <a href="/profile">profile</a>
|
||||
</Alert>
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{/* Views Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<VisibilityIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Profile Views
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
{vendor.views.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Views
|
||||
</Typography>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body1">
|
||||
{mockVendorData.views.last_30_days} in the last 30 days
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Bids Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Bids & Opportunities
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-around" textAlign="center">
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
{numBids}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Bids
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
<Chip label={newBids} color="error" size="small" />
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
New Bids
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Box display="flex" justifyContent="space-around" textAlign="center">
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
{numBidResponses}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Responded To
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
{numBidsSelected}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Selected For
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Conversations Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<ChatBubbleIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Conversations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box textAlign="center" mb={1}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{numConverstaions}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1">Unread Messages</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
Stay on top of your messages to win more bids!
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Example of other cards that could be useful */}
|
||||
|
||||
{/*<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<PaidIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Recent Payments
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h6">You have no new payments.</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>*/}
|
||||
<Grid xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={12}>
|
||||
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorDashboard;
|
||||
@@ -0,0 +1,24 @@
|
||||
// src/components/CategoryGrid.tsx
|
||||
import React from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import CategoryCard from './VideoCategory';
|
||||
import { VideoCategory } from 'pages/Education/Education';
|
||||
|
||||
interface CategoryGridProps {
|
||||
categories: VideoCategory[];
|
||||
onSelectCategory: (categoryName: string) => void;
|
||||
}
|
||||
|
||||
const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategory }) => {
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
{categories.map((category) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={category.name}>
|
||||
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryGrid;
|
||||
@@ -108,15 +108,6 @@ const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{
|
||||
id: 6,
|
||||
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,45 @@
|
||||
// src/components/VideoApp/VideoCategoryCard.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardMedia, Typography, Button, LinearProgress, Box } from '@mui/material';
|
||||
import { VideoCategory } from 'types';
|
||||
|
||||
|
||||
|
||||
interface VideoCategoryCardProps {
|
||||
category: VideoCategory;
|
||||
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
|
||||
}
|
||||
|
||||
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||
console.log(category)
|
||||
return (
|
||||
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={category.imageUrl}
|
||||
alt={category.name}
|
||||
/>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{category.description}
|
||||
</Typography>
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography>
|
||||
<LinearProgress variant="determinate" value={category.categoryProgress} sx={{ height: 8, borderRadius: 5, mt: 1 }} />
|
||||
<Typography variant="caption" display="block" align="right">{`${category.categoryProgress.toFixed(0)}%`}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<Box sx={{ p: 2, pt: 0 }}>
|
||||
<Button size="small" onClick={() => onSelectCategory(category.id)}>
|
||||
View Videos
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoCategoryCard;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,290 @@
|
||||
// src/components/VideoApp/VideoPlayer.tsx
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import {axiosInstance} from '../../../../../axiosApi';
|
||||
import { VideoItem, VideoProgressAPI } from 'types';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import PauseIcon from '@mui/icons-material/Pause';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import FullscreenIcon from '@mui/icons-material/Fullscreen';
|
||||
import ExitFullscreenIcon from '@mui/icons-material/FullscreenExit';
|
||||
|
||||
interface VideoProgress {
|
||||
id?: number; // Optional as it might not exist for new progress entries
|
||||
user?: number; // Assuming user ID is a number
|
||||
video?: number; // Video ID
|
||||
current_time?: number; // Current playback time in seconds
|
||||
completed?: boolean;
|
||||
last_watched?: string; // Optional, set by backend
|
||||
progress: number;
|
||||
}
|
||||
|
||||
|
||||
interface VideoPlayerProps {
|
||||
video: VideoItem;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
const {account, accountLoading} = useContext(AccountContext);
|
||||
|
||||
if (!video || accountLoading) {
|
||||
return (
|
||||
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">No video selected</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
//
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Function to save progress to the backend
|
||||
const saveProgress = useCallback(async (time: number, completed: boolean = false) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const progressData: VideoProgress = {
|
||||
|
||||
progress: Math.round(time),
|
||||
|
||||
};
|
||||
|
||||
try {
|
||||
// First, try to fetch existing progress
|
||||
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`);
|
||||
if (response.data.length > 0) {
|
||||
// If progress exists, update it
|
||||
const existingProgress = response.data[0];
|
||||
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
|
||||
} else {
|
||||
// If no progress, create a new one
|
||||
await axiosInstance.post('/videos/progress/', progressData);
|
||||
}
|
||||
if (completed) {
|
||||
setSnackbarMessage('Video progress saved: Completed!');
|
||||
} else {
|
||||
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
|
||||
}
|
||||
setSnackbarOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to save video progress:', err);
|
||||
setError('Failed to save video progress.');
|
||||
}
|
||||
}, [video.id, account?.id]);
|
||||
|
||||
// Fetch initial progress when video changes or component mounts
|
||||
useEffect(() => {
|
||||
const fetchProgress = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const {data,} = await axiosInstance.get<VideoProgressAPI>(`/videos/progress/${video.id}/?user=${account?.id}`);
|
||||
|
||||
if (data) {
|
||||
|
||||
const progress: VideoProgress = {
|
||||
current_time: data.progress,
|
||||
progress: data.progress,
|
||||
}
|
||||
setCurrentTime(progress?.current_time || 0);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = progress.current_time || 0;
|
||||
}
|
||||
setSnackbarMessage(`Resuming from ${Math.floor(progress?.current_time || 0)}s`);
|
||||
setSnackbarOpen(true);
|
||||
} else {
|
||||
setCurrentTime(0);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch video progress:', err);
|
||||
setError('Failed to load video progress.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
|
||||
// Cleanup on unmount or video change
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
// Save final progress when component unmounts or video changes
|
||||
if (videoRef.current && !videoRef.current.ended) {
|
||||
saveProgress(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
}, [video.id, account?.id, saveProgress]);
|
||||
|
||||
|
||||
// Video event handlers
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const newTime = videoRef.current.currentTime;
|
||||
setCurrentTime(newTime);
|
||||
|
||||
// Debounce progress saving to avoid too many API calls
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
saveProgress(newTime);
|
||||
}, 5000); // Save every 5 seconds of playback
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(e => console.error("Error playing video:", e));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
saveProgress(videoRef.current.currentTime); // Save immediately on pause
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
saveProgress(video.duration, true); // Mark as completed
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
setIsLoading(false);
|
||||
// Attempt to play if it was playing before, or if it's the first load
|
||||
if (videoRef.current && currentTime > 0) {
|
||||
videoRef.current.currentTime = currentTime;
|
||||
videoRef.current.play().catch(e => console.error("Error resuming video:", e));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
console.error('Video error:', event);
|
||||
setError('Error loading video. The file might be missing or corrupted.');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleReplay = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
saveProgress(0, false); // Reset progress
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.current.requestFullscreen().catch(err => {
|
||||
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullScreenChange = () => {
|
||||
setIsFullScreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
|
||||
};
|
||||
|
||||
const handleSnackbarClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Paper elevation={3} sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>{video.name}</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>{video.description}</Typography>
|
||||
<Box sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
controls={false}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleEnded}
|
||||
onLoadedData={handleLoadedData}
|
||||
onError={handleVideoError}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#000',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
|
||||
|
||||
>
|
||||
<Typography>
|
||||
Your browser does nto support the video tag.
|
||||
</Typography>
|
||||
</video>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title={isPlaying ? 'Pause' : 'Play'}>
|
||||
<IconButton onClick={isPlaying ? handlePause : handlePlay} color="primary" size="large">
|
||||
{isPlaying ? <PauseIcon fontSize="inherit" /> : <PlayArrowIcon fontSize="inherit" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Replay">
|
||||
<IconButton onClick={handleReplay} color="primary" size="large">
|
||||
<ReplayIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
{formatTime(currentTime)} / {formatTime(video.duration)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
|
||||
<IconButton onClick={toggleFullScreen} color="primary" size="large">
|
||||
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
Current Progress: {Math.round(video.progress/video.duration*100)}% - Status: {video.status}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,500 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Grid,
|
||||
Autocomplete,
|
||||
CircularProgress,
|
||||
Box,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||
|
||||
import MapComponent from '../../../../base/MapComponent';
|
||||
import {
|
||||
AutocompleteDataResponseAPI,
|
||||
AutocompleteResponseAPI,
|
||||
PropertiesAPI,
|
||||
PropertResponseDataAPI,
|
||||
PropertyResponseAPI,
|
||||
SaleHistoryAPI,
|
||||
SchoolAPI,
|
||||
} from 'types';
|
||||
import { test_property_search } from 'data/mock_property_search';
|
||||
import { extractLatLon } from 'utils';
|
||||
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||
|
||||
interface AddPropertyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddProperty: (
|
||||
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface PlacePrediction {
|
||||
description: string;
|
||||
place_id: string;
|
||||
}
|
||||
|
||||
const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, onAddProperty }) => {
|
||||
const initalValues: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'> = {
|
||||
address: '',
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip_code: '',
|
||||
market_value: '',
|
||||
loan_amount: '',
|
||||
loan_term: 0,
|
||||
loan_start_date: '',
|
||||
pictures: [],
|
||||
description: '',
|
||||
sq_ft: 0,
|
||||
features: [],
|
||||
num_bedrooms: 0,
|
||||
num_bathrooms: 0,
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
realestate_api_id: 0,
|
||||
views: 0,
|
||||
saves: 0,
|
||||
property_status: 'off_market',
|
||||
schools: [],
|
||||
};
|
||||
const [newProperty, setNewProperty] = useState<
|
||||
Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>
|
||||
>({
|
||||
...initalValues,
|
||||
});
|
||||
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||
const [selectedPlace, setSelectedPlace] = useState<PlacePrediction | null>(null);
|
||||
|
||||
// Initialize Google Maps Places Service (requires Google Maps API key loaded globally)
|
||||
// This is a simplified approach. For a more robust solution, use @vis.gl/react-google-maps useMapsLibrary hook
|
||||
useEffect(() => {
|
||||
if (!window.google || !window.google.maps || !window.google.maps.places) {
|
||||
console.warn('Google Maps Places API not loaded. Autocomplete will not function.');
|
||||
// You might want to handle this by displaying a message or disabling autocomplete
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setNewProperty((prev) => ({ ...prev, [name]: value }));
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteInputChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||
// filter the data here
|
||||
} else {
|
||||
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||
'AutoComplete',
|
||||
{
|
||||
search: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
setAutocompleteOptions(
|
||||
data.map((item) => ({
|
||||
description: item.address,
|
||||
place_id: item.id,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log('we need more characters');
|
||||
}
|
||||
|
||||
setNewProperty((prev) => ({ ...prev, address: value }));
|
||||
// if (value.length > 2 && window.google && window.google.maps && window.google.maps.places) {
|
||||
// setAutocompleteLoading(true);
|
||||
// const service = new window.google.maps.places.AutocompleteService();
|
||||
// service.getPlacePredictions({ input: value }, (predictions, status) => {
|
||||
// if (status === window.google.maps.places.PlacesServiceStatus.OK && predictions) {
|
||||
// setAutocompleteOptions(predictions.map(p => ({ description: p.description, place_id: p.place_id })));
|
||||
// } else {
|
||||
// setAutocompleteOptions([]);
|
||||
// }
|
||||
// setAutocompleteLoading(false);
|
||||
// });
|
||||
// } else {
|
||||
// setAutocompleteOptions([]);
|
||||
// }
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: PlacePrediction | null,
|
||||
) => {
|
||||
setSelectedPlace(value);
|
||||
console.log('here we go', value);
|
||||
if (value) {
|
||||
console.log('find the test data');
|
||||
const test: boolean = true;
|
||||
if (test) {
|
||||
const parts: string[] =
|
||||
test_property_search.data.currentMortgages[0].recordingDate.split('T');
|
||||
|
||||
// get the features
|
||||
|
||||
// get the schools
|
||||
|
||||
const schools: Omit<SchoolAPI, 'id' | 'created_at' | 'last_updated'>[] =
|
||||
test_property_search.data.schools.map((item) => {
|
||||
const coordinates = extractLatLon(item.location);
|
||||
return {
|
||||
city: item.city,
|
||||
state: item.state,
|
||||
zip_code: item.zip,
|
||||
latitude: coordinates?.latitude,
|
||||
longitude: coordinates?.longitude,
|
||||
school_type: item.type,
|
||||
enrollment: item.enrollment,
|
||||
grades: item.grades,
|
||||
name: item.name,
|
||||
parent_rating: item.parentRating,
|
||||
rating: item.rating,
|
||||
};
|
||||
});
|
||||
console.log(schools);
|
||||
|
||||
// get the sale history
|
||||
const sale_history: Omit<SaleHistoryAPI, 'id' | 'created_at' | 'last_updated'>[] =
|
||||
test_property_search.data.saleHistory.map((item) => {
|
||||
return {
|
||||
seq_no: item.seqNo,
|
||||
sale_date: item.saleDate,
|
||||
sale_amount: item.saleAmount,
|
||||
};
|
||||
});
|
||||
|
||||
setNewProperty({
|
||||
address: test_property_search.data.propertyInfo.address.address,
|
||||
street: test_property_search.data.ownerInfo.mailAddress.address,
|
||||
city: test_property_search.data.propertyInfo.address.city,
|
||||
state: test_property_search.data.propertyInfo.address.state,
|
||||
zip_code: test_property_search.data.propertyInfo.address.zip,
|
||||
latitude: test_property_search.data.propertyInfo.latitude,
|
||||
longitude: test_property_search.data.propertyInfo.longitude,
|
||||
market_value: test_property_search.data.estimatedValue.toString(),
|
||||
loan_amount: test_property_search.data.currentMortgages[0].amount.toString(),
|
||||
loan_term: test_property_search.data.currentMortgages[0].term,
|
||||
loan_start_date: parts[0],
|
||||
description: '',
|
||||
features: [],
|
||||
pictures: [],
|
||||
num_bedrooms: test_property_search.data.propertyInfo.bedrooms,
|
||||
num_bathrooms: test_property_search.data.propertyInfo.bathrooms,
|
||||
sq_ft: test_property_search.data.propertyInfo.buildingSquareFeet,
|
||||
realestate_api_id: test_property_search.data.id,
|
||||
views: 0,
|
||||
saves: 0,
|
||||
property_status: 'off_market',
|
||||
schools: schools,
|
||||
tax_info: {
|
||||
assessed_value: test_property_search.data.taxInfo.assessedValue,
|
||||
assessment_year: test_property_search.data.taxInfo.assessmentYear,
|
||||
tax_amount: Number(test_property_search.data.taxInfo.taxAmount),
|
||||
year: test_property_search.data.taxInfo.year,
|
||||
},
|
||||
sale_info: sale_history,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple file upload simulation (you'd replace this with actual file handling and storage)
|
||||
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
// In a real app, you'd upload these files and get URLs back
|
||||
const imageUrls = filesArray.map((file) => URL.createObjectURL(file)); // For display purposes
|
||||
setNewProperty((prev) => ({ ...prev, pictures: [...prev.pictures, ...imageUrls] }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (!newProperty.address.trim()) {
|
||||
errors.address = 'Address is required.';
|
||||
}
|
||||
if (!newProperty.city.trim()) {
|
||||
errors.city = 'City is required.';
|
||||
}
|
||||
if (!newProperty.state.trim()) {
|
||||
errors.state = 'State is required.';
|
||||
}
|
||||
if (!newProperty.zip_code.trim()) {
|
||||
errors.zip_code = 'Zip code is required.';
|
||||
}
|
||||
if (newProperty.sq_ft <= 0) {
|
||||
errors.sq_ft = 'Square footage must be greater than 0.';
|
||||
}
|
||||
if (newProperty.num_bedrooms < 0) {
|
||||
errors.num_bedrooms = 'Number of bedrooms cannot be negative.';
|
||||
}
|
||||
if (newProperty.num_bathrooms < 0) {
|
||||
errors.num_bathrooms = 'Number of bathrooms cannot be negative.';
|
||||
}
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (validateForm()) {
|
||||
onAddProperty(newProperty);
|
||||
onClose();
|
||||
// Reset form
|
||||
setNewProperty(initalValues);
|
||||
setAutocompleteOptions([]);
|
||||
setSelectedPlace(null);
|
||||
setFormErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseAndReset = () => {
|
||||
onClose();
|
||||
setNewProperty(initalValues);
|
||||
setAutocompleteOptions([]);
|
||||
setSelectedPlace(null);
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleCloseAndReset} fullWidth maxWidth="md">
|
||||
<DialogTitle>Add New Property</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
inputValue={newProperty.address}
|
||||
noOptionsText={'Type at least 3 characters'}
|
||||
onInputChange={handleAddressAutocompleteInputChange}
|
||||
onChange={handleAddressAutocompleteChange}
|
||||
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||
loading={autocompleteLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Address"
|
||||
name="address"
|
||||
fullWidth
|
||||
required
|
||||
error={!!formErrors.address}
|
||||
helperText={formErrors.address}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{autocompleteLoading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
name="city"
|
||||
value={newProperty.city}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
error={!!formErrors.city}
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
name="state"
|
||||
value={newProperty.state}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
error={!!formErrors.state}
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
name="zip_code"
|
||||
value={newProperty.zip_code}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
error={!!formErrors.zip_code}
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
name="description"
|
||||
multiline
|
||||
rows={3}
|
||||
value={newProperty.description}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Square Footage"
|
||||
name="sq_ft"
|
||||
type="number"
|
||||
value={newProperty.sq_ft || ''}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.sq_ft}
|
||||
helperText={formErrors.sq_ft}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="# Bedrooms"
|
||||
name="num_bedrooms"
|
||||
type="number"
|
||||
value={newProperty.num_bedrooms || ''}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.num_bedrooms}
|
||||
helperText={formErrors.num_bedrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="# Bathrooms"
|
||||
name="num_bathrooms"
|
||||
type="number"
|
||||
value={newProperty.num_bathrooms || ''}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.num_bathrooms}
|
||||
helperText={formErrors.num_bathrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Features (comma-separated)"
|
||||
name="features"
|
||||
value={newProperty.features.join(', ')}
|
||||
onChange={(e) =>
|
||||
setNewProperty((prev) => ({
|
||||
...prev,
|
||||
features: e.target.value.split(',').map((f) => f.trim()),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Market Value"
|
||||
name="market_value"
|
||||
value={newProperty.market_value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Amount"
|
||||
name="loan_amount"
|
||||
value={newProperty.loan_amount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Term (years)"
|
||||
name="loan_term"
|
||||
type="number"
|
||||
value={newProperty.loan_term || ''}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Start Date"
|
||||
name="loan_start_date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={newProperty.loan_start_date}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button variant="contained" component="label">
|
||||
Upload Pictures
|
||||
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
|
||||
</Button>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{newProperty.pictures.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`Uploaded ${index}`}
|
||||
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
{newProperty.latitude && newProperty.longitude && (
|
||||
<Grid item xs={12}>
|
||||
<MapComponent
|
||||
lat={newProperty.latitude}
|
||||
lng={newProperty.longitude}
|
||||
address={newProperty.address}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{Object.keys(formErrors).length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Please correct the errors in the form.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseAndReset}>Cancel</Button>
|
||||
<Button onClick={handleAdd} variant="contained" color="primary">
|
||||
Add Property
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPropertyDialog;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect, ReactElement } from 'react';
|
||||
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
|
||||
|
||||
import { AttorneyAPI, UserAPI } from '../types/api';
|
||||
import { ProfileProps } from 'pages/Profile/Profile';
|
||||
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||
import AttorneyProfileCard from './AttorneyProfileCard';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
const [attorney, setAttorney] = useState<AttorneyAPI | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchAttorneyData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data }: AxiosResponse<AttorneyAPI[]> = await axiosInstance.get('/attorney/');
|
||||
if (data.length > 0) {
|
||||
setAttorney(data[0]);
|
||||
}
|
||||
// setAttorney(initialAttorneyData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred while fetching profile data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttorneyData();
|
||||
}, []);
|
||||
|
||||
const handleUpgradeSubscription = () => {
|
||||
if (attorney) {
|
||||
navigate('/upgrade/');
|
||||
//setAttorney((prev) => (prev ? { ...prev, user: { ...prev.user, tier: 'premium' } } : null));
|
||||
//setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||
//setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAttorneyProfile = (updatedAttorney: AttorneyAPI) => {
|
||||
// In a real application, you'd send this data to your backend API
|
||||
try {
|
||||
let newUpdatedAttorney;
|
||||
// if the email is the same, remove it
|
||||
if (updatedAttorney.user.email === account.email) {
|
||||
const { user, ...reducedVendor } = updatedAttorney;
|
||||
const { email, ...reducedUser } = user;
|
||||
newUpdatedAttorney = {
|
||||
user: {
|
||||
profile_created: true,
|
||||
...reducedUser,
|
||||
},
|
||||
...reducedVendor,
|
||||
};
|
||||
} else {
|
||||
newUpdatedAttorney = updatedAttorney;
|
||||
}
|
||||
newUpdatedAttorney.user.profile_created = true;
|
||||
console.log(newUpdatedAttorney);
|
||||
|
||||
const { data, error } = axiosInstance.patch(`/attorney/${account.id}/`, {
|
||||
...newUpdatedAttorney,
|
||||
});
|
||||
console.log(data, error);
|
||||
setAttorney({
|
||||
profile: { profile_created: true, ...updatedAttorney.profile },
|
||||
...updatedAttorney,
|
||||
});
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
// console.log('Saving attorney profile:', updatedAttorney);
|
||||
// setAttorney(updatedAttorney);
|
||||
// setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
// setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Attorney Profile
|
||||
</Typography>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
<AttorneyProfileCard
|
||||
attorney={attorney}
|
||||
onUpgrade={handleUpgradeSubscription}
|
||||
onSave={handleSaveAttorneyProfile}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttorneyProfile;
|
||||
@@ -0,0 +1,503 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
TextField,
|
||||
Box,
|
||||
Alert,
|
||||
IconButton,
|
||||
Chip,
|
||||
Avatar,
|
||||
Autocomplete,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
||||
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||
import MapComponent from 'components/base/MapComponent';
|
||||
import { AttorneyAPI, AutocompleteDataResponseAPI } from 'types';
|
||||
import { PlacePrediction } from './AddPropertyDialog';
|
||||
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||
import { extractLatLon } from 'utils';
|
||||
|
||||
interface AttorneyProfileCardProps {
|
||||
attorney: AttorneyAPI;
|
||||
onUpgrade: () => void; // Assuming attorneys can also upgrade their tier
|
||||
onSave: (updatedAttorney: AttorneyAPI) => void;
|
||||
}
|
||||
|
||||
const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
attorney,
|
||||
onUpgrade,
|
||||
onSave,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAttorney, setEditedAttorney] = useState<AttorneyAPI>(attorney);
|
||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
setEditedAttorney(attorney); // Revert on cancel
|
||||
setFormErrors({});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditedAttorney((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
// Handle nested user properties if you allow editing them here
|
||||
user:
|
||||
name === 'email' || name === 'first_name' || name === 'last_name'
|
||||
? { ...prev.user, [name]: value }
|
||||
: prev.user,
|
||||
}));
|
||||
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const numValue = parseInt(value);
|
||||
setEditedAttorney((prev) => ({
|
||||
...prev,
|
||||
[name]: isNaN(numValue) ? '' : numValue, // Allow empty string for numerical input
|
||||
}));
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArrayChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
field: keyof AttorneyAPI,
|
||||
) => {
|
||||
const value = e.target.value;
|
||||
setEditedAttorney((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== ''),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleProfilePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0];
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
setEditedAttorney((prev) => ({ ...prev, profile_picture: imageUrl }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteInputChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||
// filter the data here
|
||||
} else {
|
||||
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||
'AutoComplete',
|
||||
{
|
||||
search: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
setAutocompleteOptions(
|
||||
data.map((item) => ({
|
||||
description: item.address,
|
||||
place_id: item.id,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log('we need more characters');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: PlacePrediction | null,
|
||||
) => {
|
||||
if (1) {
|
||||
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
|
||||
if (data.length > 0) {
|
||||
const item = data[0];
|
||||
const coordinates = extractLatLon(item.location);
|
||||
setEditedAttorney((prev) => ({
|
||||
...prev,
|
||||
address: item.address,
|
||||
city: item.city,
|
||||
state: item.state,
|
||||
zip_code: item.zip,
|
||||
latitude: Number(coordinates.latitude),
|
||||
longitude: Number(coordinates.longitude),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// use the api here
|
||||
}
|
||||
};
|
||||
|
||||
console.log(editedAttorney);
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (!editedAttorney.firm_name.trim()) {
|
||||
errors.firm_name = 'Firm name is required.';
|
||||
}
|
||||
if (!editedAttorney.phone_number.trim()) {
|
||||
errors.phone_number = 'Phone number is required.';
|
||||
} else if (!/^\d{10}$/.test(editedAttorney.phone_number.replace(/\D/g, ''))) {
|
||||
errors.phone_number = 'Invalid phone number format (10 digits).';
|
||||
}
|
||||
if (!editedAttorney.address.trim()) {
|
||||
errors.address = 'Address is required.';
|
||||
}
|
||||
if (!editedAttorney.city.trim()) {
|
||||
errors.city = 'City is required.';
|
||||
}
|
||||
if (!editedAttorney.state.trim()) {
|
||||
errors.state = 'State is required.';
|
||||
}
|
||||
if (!editedAttorney.zip_code.trim()) {
|
||||
errors.zip_code = 'Zip code is required.';
|
||||
}
|
||||
if (editedAttorney.years_experience < 0) {
|
||||
errors.years_experience = 'Years of experience cannot be negative.';
|
||||
}
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
onSave(editedAttorney);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayLat = editedAttorney.latitude ?? 34.0522; // Default to LA for demo
|
||||
const displayLng = editedAttorney.longitude ?? -118.2437; // Default to LA for demo
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Avatar
|
||||
src={
|
||||
editedAttorney.profile_picture ||
|
||||
'https://via.placeholder.com/150/808080/FFFFFF?text=ATTY'
|
||||
}
|
||||
alt={`${editedAttorney.user.first_name} ${editedAttorney.user.last_name}`}
|
||||
sx={{ width: 80, height: 80 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Attorney Profile Information
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{editedAttorney.firm_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isEditing ? (
|
||||
<Box>
|
||||
<IconButton color="primary" onClick={handleSave}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton color="primary" onClick={handleEditToggle}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
name="first_name"
|
||||
value={editedAttorney.user.first_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
name="last_name"
|
||||
value={editedAttorney.user.last_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
name="email"
|
||||
type="email"
|
||||
value={editedAttorney.user.email}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Firm Name"
|
||||
name="firm_name"
|
||||
value={editedAttorney.firm_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.firm_name}
|
||||
helperText={formErrors.firm_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Phone Number"
|
||||
name="phone_number"
|
||||
value={editedAttorney.phone_number}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.phone_number}
|
||||
helperText={formErrors.phone_number}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
inputValue={editedAttorney.adress}
|
||||
noOptionsText={'Type at least 3 characters'}
|
||||
onInputChange={handleAddressAutocompleteInputChange}
|
||||
onChange={handleAddressAutocompleteChange}
|
||||
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||
loading={autocompleteLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
label="Address"
|
||||
name="address"
|
||||
required
|
||||
error={!!formErrors.address}
|
||||
helperText={formErrors.address}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{autocompleteLoading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/*<TextField
|
||||
fullWidth
|
||||
label="Address"
|
||||
name="address"
|
||||
value={editedAttorney.address}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.address}
|
||||
helperText={formErrors.address}
|
||||
/>*/}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
name="city"
|
||||
value={editedAttorney.city}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.city}
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
name="state"
|
||||
value={editedAttorney.state}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.state}
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
name="zip_code"
|
||||
value={editedAttorney.zip_code}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.zip_code}
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
{/*<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Specialties (comma-separated)"
|
||||
name="specialties"
|
||||
value={editedAttorney.specialties.join(', ')}
|
||||
onChange={(e) => handleArrayChange(e, 'specialties')}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{editedAttorney.specialties.map((specialty, index) => (
|
||||
<Chip key={index} label={specialty} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>*/}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Years of Experience"
|
||||
name="years_experience"
|
||||
type="number"
|
||||
value={editedAttorney.years_experience || ''}
|
||||
onChange={handleNumericChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.years_experience}
|
||||
helperText={formErrors.years_experience}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Licensed States (comma-separated)"
|
||||
name="licensed_states"
|
||||
value={editedAttorney.licensed_states.join(', ')}
|
||||
onChange={(e) => handleArrayChange(e, 'licensed_states')}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{editedAttorney.licensed_states.map((state, index) => (
|
||||
<Chip key={index} label={state} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
name="website"
|
||||
value={editedAttorney.website || ''}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Biography"
|
||||
name="bio"
|
||||
multiline
|
||||
rows={4}
|
||||
value={editedAttorney.bio || ''}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
startIcon={<AddPhotoAlternateIcon />}
|
||||
disabled={!isEditing}
|
||||
>
|
||||
Upload Profile Picture
|
||||
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
{attorney.user.tier === 'basic' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onUpgrade}
|
||||
sx={{ mt: 1 }}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Upgrade to Premium
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
{editedAttorney.latitude && editedAttorney.longitude && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Firm Location on Map:
|
||||
</Typography>
|
||||
{/* Assuming MapComponent accepts center, zoom, and a single property for display */}
|
||||
<MapComponent
|
||||
lat={editedAttorney.latitude}
|
||||
lng={editedAttorney.longitude}
|
||||
address={editedAttorney.address}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{Object.keys(formErrors).length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Please correct the errors in the form.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttorneyProfileCard;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, TextField, Button, Box, Alert } from '@mui/material';
|
||||
|
||||
interface OfferSubmissionCardProps {
|
||||
onOfferSubmit: (offerAmount: number) => void;
|
||||
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
|
||||
}
|
||||
|
||||
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
||||
onOfferSubmit,
|
||||
listingStatus,
|
||||
}) => {
|
||||
const [offerAmount, setOfferAmount] = useState<string>('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
if (listingStatus === 'active') {
|
||||
const handleSubmit = () => {
|
||||
const amount = parseFloat(offerAmount);
|
||||
if (amount > 0) {
|
||||
onOfferSubmit(amount);
|
||||
setSubmitted(true);
|
||||
setTimeout(() => setSubmitted(false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Submit an Offer
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Your Offer Amount ($)"
|
||||
type="number"
|
||||
value={offerAmount}
|
||||
onChange={(e) => setOfferAmount(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Button variant="contained" color="primary" fullWidth onClick={handleSubmit}>
|
||||
Submit Offer
|
||||
</Button>
|
||||
{submitted && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Your offer of ${offerAmount} has been submitted!
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Submit an Offer
|
||||
</Typography>
|
||||
<Typography variant="caption" gutterBottom>
|
||||
Offer is not available at the moment because the list is not active
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default OfferSubmissionCard;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Divider } from '@mui/material';
|
||||
import { OpenHouseAPI } from 'types';
|
||||
|
||||
|
||||
interface OpenHouseCardProps {
|
||||
openHouses: OpenHouseAPI[] | undefined;
|
||||
}
|
||||
|
||||
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
|
||||
if(openHouses){
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Open House Information
|
||||
</Typography>
|
||||
{openHouses.length > 0 ? (
|
||||
<List dense>
|
||||
{openHouses.map((openHouse, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`${openHouse.date} at ${openHouse.time}`}
|
||||
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < openHouses.length - 1 && <Divider component="li" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No upcoming open houses scheduled.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
}else{
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Open House Information
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No upcoming open houses scheduled.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default OpenHouseCard;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
TextField,
|
||||
Box,
|
||||
Alert,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { UserAPI } from 'types';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
|
||||
interface ProfileCardProps {
|
||||
user: UserAPI;
|
||||
onUpgrade: () => void;
|
||||
onSave: (updatedUser: UserAPI) => void;
|
||||
}
|
||||
|
||||
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedUser, setEditedUser] = useState<UserAPI>(user);
|
||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
// Cancel editing, revert to original user data
|
||||
setEditedUser(user);
|
||||
setFormErrors({});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
console.log(editedUser);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEditedUser((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
// Clear error for the field being edited
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (!editedUser.first_name.trim()) {
|
||||
errors.first_name = 'First name is required.';
|
||||
}
|
||||
if (!editedUser.last_name.trim()) {
|
||||
errors.last_name = 'Last name is required.';
|
||||
}
|
||||
if (!editedUser.email.trim()) {
|
||||
errors.email = 'Email is required.';
|
||||
} else if (!/\S+@\S+\.\S+/.test(editedUser.email)) {
|
||||
errors.email = 'Invalid email address.';
|
||||
}
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
onSave(editedUser);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Profile Information
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Box>
|
||||
<IconButton color="primary" onClick={handleSave}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton color="primary" onClick={handleEditToggle}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
name="first_name"
|
||||
value={editedUser.first_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.first_name}
|
||||
helperText={formErrors.first_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
name="last_name"
|
||||
value={editedUser.last_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.last_name}
|
||||
helperText={formErrors.last_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
name="email"
|
||||
type="email"
|
||||
value={editedUser.email}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.email}
|
||||
helperText={formErrors.email}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
{user.tier === 'basic' && (
|
||||
<Button variant="contained" color="primary" onClick={onUpgrade} sx={{ mt: 1 }}>
|
||||
Upgrade to Premium
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1">Notification Settings:</Typography>
|
||||
{/* Example Checkboxes - You'd manage these with state too */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="productUpdateEmails"
|
||||
checked={true} // Placeholder
|
||||
onChange={() => {}} // Placeholder
|
||||
disabled={!isEditing}
|
||||
/>{' '}
|
||||
Product Update Emails
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="communicationEmails"
|
||||
checked={true} // Placeholder
|
||||
onChange={() => {}} // Placeholder
|
||||
disabled={!isEditing}
|
||||
/>{' '}
|
||||
Communication Emails
|
||||
</label>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{Object.keys(formErrors).length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Please correct the errors in the form.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCard;
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
Box,
|
||||
ImageList,
|
||||
ImageListItem,
|
||||
ImageListItemBar,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
import MapComponent from '../../../../base/MapComponent';
|
||||
import { PropertiesAPI } from 'types';
|
||||
|
||||
interface PropertyCardProps {
|
||||
property: PropertiesAPI;
|
||||
}
|
||||
|
||||
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
|
||||
// Dummy latitude and longitude for demonstration
|
||||
// In a real app, you'd geocode the address to get these.
|
||||
const demoLat = 34.0522;
|
||||
const demoLng = -118.2437; // Example: Los Angeles coordinates
|
||||
console.log(property)
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{property.address}, {property.city}, {property.state} {property.zip_code}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
{property.pictures && property.pictures.length > 0 && (
|
||||
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}>
|
||||
{property.pictures.map((item, index) => (
|
||||
<ImageListItem key={index}>
|
||||
<img
|
||||
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
|
||||
${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
||||
src={`${item}?w=164&h=164&fit=crop&auto=format`}
|
||||
alt={`Property image ${index + 1}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ImageListItemBar
|
||||
title={`Image ${index + 1}`}
|
||||
/>
|
||||
</ImageListItem>
|
||||
))}
|
||||
</ImageList>
|
||||
)}
|
||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||
<strong>Description:</strong>
|
||||
</Typography>
|
||||
{ property.description ? (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{property.description}
|
||||
</Typography>
|
||||
|
||||
) : (
|
||||
<Button variant='contained'>
|
||||
Generate Description
|
||||
</Button>
|
||||
|
||||
)}
|
||||
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body1">
|
||||
<strong>Stats:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Sq Ft: {property.sq_ft || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Bedrooms: {property.num_bedrooms || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Bathrooms: {property.num_bathrooms || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Features: {property.features && property.features.length > 0
|
||||
? property.features.join(', ')
|
||||
: 'None'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Market Value: ${property.market_value || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Amount: ${property.loan_amount || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||
</Typography>
|
||||
{property.latitude && property.longitude ? (
|
||||
<MapComponent lat={property.latitude} lng={property.longitude} address={property.address} />
|
||||
) : (
|
||||
<p>Error loading the map</p>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyCard;
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
|
||||
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI } from 'types';
|
||||
import ProfileCard from './ProfileCard';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import PropertyCard from './PropertyCard.';
|
||||
import AddPropertyDialog from './AddPropertyDialog';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||
import { ProfileProps } from 'pages/Profile/Profile';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPropertyOwner = async () => {
|
||||
try {
|
||||
setLoadingData(true);
|
||||
const { data }: AxiosResponse<PropertyOwnerAPI[]> =
|
||||
await axiosInstance.get(`/property-owners/`);
|
||||
if (data.length > 0) {
|
||||
setUser(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchPropertyOwner();
|
||||
}, []);
|
||||
|
||||
const [properties, setProperties] = useState<PropertiesAPI[]>([]); //initialPropertiesData);
|
||||
|
||||
const [openAddPropertyDialog, setOpenAddPropertyDialog] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProperties = async () => {
|
||||
try {
|
||||
setLoadingData(true);
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||
if (data.length > 0) {
|
||||
setProperties(data);
|
||||
console.log('setting the user to: ', data[0].owner);
|
||||
setUser(data[0].owner);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
const handleUpgradeSubscription = async () => {
|
||||
navigate('/upgrade/');
|
||||
// const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
|
||||
// ...user.user,
|
||||
// tier: 'premium',
|
||||
// });
|
||||
// if (data !== null && user) {
|
||||
// const updateUser: PropertyOwnerAPI = {
|
||||
// ...user,
|
||||
// user: data,
|
||||
// };
|
||||
// setUser(updateUser);
|
||||
// }
|
||||
|
||||
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||
// setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (editedUser: UserAPI) => {
|
||||
editedUser.profile_created = true;
|
||||
const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
|
||||
...editedUser,
|
||||
});
|
||||
if (data !== null && user) {
|
||||
const updateUser: PropertyOwnerAPI = {
|
||||
...user,
|
||||
user: data,
|
||||
};
|
||||
setUser(updateUser);
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleOpenAddPropertyDialog = () => {
|
||||
setOpenAddPropertyDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseAddPropertyDialog = () => {
|
||||
setOpenAddPropertyDialog(false);
|
||||
};
|
||||
|
||||
const handleAddProperty = (
|
||||
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||
) => {
|
||||
if (user) {
|
||||
const newProperty: PropertyRequestAPI = {
|
||||
...newPropertyData,
|
||||
owner: user.user.id,
|
||||
// created_at: new Date().toISOString().split('T')[0],
|
||||
// last_updated: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
newProperty.created_at = new Date().toISOString().split('T')[0];
|
||||
newProperty.last_updated = new Date().toISOString().split('T')[0];
|
||||
|
||||
newProperty.open_houses = [];
|
||||
|
||||
console.log(newProperty);
|
||||
const { data, error } = axiosInstance.post('/properties/', {
|
||||
...newProperty,
|
||||
});
|
||||
const updateNewProperty: PropertiesAPI = {
|
||||
...newProperty,
|
||||
owner: user,
|
||||
};
|
||||
setProperties((prev) => [...prev, updateNewProperty]);
|
||||
setMessage({ type: 'success', text: 'Property added successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
// In a real app, this would be an API call to update the property
|
||||
console.log('Saving property: IMPLEMENT ME', updatedProperty);
|
||||
};
|
||||
|
||||
const handleDeleteProperty = async (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
try {
|
||||
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete(
|
||||
`/properties/${propertyId}/`,
|
||||
);
|
||||
console.log(data);
|
||||
// remove the proprty from the list
|
||||
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId));
|
||||
// const indexToRemove = properties.findIndex(property => property.id === propertyId);
|
||||
// console.log(indexToRemove)
|
||||
// if (indexToRemove !== -1) {
|
||||
// const updatedProperties = properties.splice(indexToRemove, 1)
|
||||
// console.log(updatedProperties)
|
||||
// setProperties(updatedProperties);
|
||||
// }
|
||||
} catch {
|
||||
console.log('error removing');
|
||||
}
|
||||
};
|
||||
|
||||
console.log(user);
|
||||
if (loadingData) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return <DashboardErrorPage />;
|
||||
} else {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
User Profile Dashboard
|
||||
</Typography>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
<ProfileCard
|
||||
user={user.user}
|
||||
onUpgrade={handleUpgradeSubscription}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
|
||||
My Properties
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" onClick={handleOpenAddPropertyDialog}>
|
||||
Add Property
|
||||
</Button>
|
||||
</Box>
|
||||
{properties.length === 0 ? (
|
||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
You currently have no properties listed.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{properties.map((property) => (
|
||||
<Grid item xs={12} key={property.id}>
|
||||
{/* <PropertyCard property={property} /> */}
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
isPublicPage={false}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={true}
|
||||
onDelete={handleDeleteProperty}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<AddPropertyDialog
|
||||
open={openAddPropertyDialog}
|
||||
onClose={handleCloseAddPropertyDialog}
|
||||
onAddProperty={handleAddProperty}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default PropertyOwnerProfile;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { UserAPI, VendorAPI } from 'types';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
Alert,
|
||||
Avatar,
|
||||
Rating,
|
||||
Grid,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import VendorProfileCard from './VendorProfileCard';
|
||||
import ServicesCard from './ServiceCard';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||
import { ProfileProps } from 'pages/Profile/Profile';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const VendorProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
const [vendor, setVendor] = useState<VendorAPI | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVendor = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
|
||||
if (data.length > 0) {
|
||||
setVendor(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchVendor();
|
||||
}, []);
|
||||
if (loadingData) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (vendor === null) {
|
||||
return <DashboardErrorPage />;
|
||||
} else {
|
||||
const handleUpgradeSubscription = () => {
|
||||
navigate('/upgrade/');
|
||||
// setVendor((prev) => ({
|
||||
// ...prev,
|
||||
// user: { ...prev.user, tier: 'premium' },
|
||||
// }));
|
||||
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||
// setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleSaveVendorProfile = (updatedVendor: VendorAPI) => {
|
||||
try {
|
||||
let newUpdatedVendor;
|
||||
// if the email is the same, remove it
|
||||
if (updatedVendor.user.email === account.email) {
|
||||
const { user, ...reducedVendor } = updatedVendor;
|
||||
const { email, ...reducedUser } = user;
|
||||
newUpdatedVendor = {
|
||||
user: {
|
||||
profile_created: true,
|
||||
...reducedUser,
|
||||
},
|
||||
...reducedVendor,
|
||||
};
|
||||
} else {
|
||||
newUpdatedVendor = updatedVendor;
|
||||
}
|
||||
newUpdatedVendor.user.profile_created = true;
|
||||
|
||||
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||
...newUpdatedVendor,
|
||||
});
|
||||
setVendor(updatedVendor);
|
||||
setMessage({ type: 'success', text: 'Vendor profile updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveServices = (updatedServices: string[]) => {
|
||||
try {
|
||||
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||
services: updatedServices,
|
||||
});
|
||||
setVendor((prev) => ({
|
||||
...prev,
|
||||
services: updatedServices,
|
||||
}));
|
||||
setMessage({ type: 'success', text: 'Services updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Error saving services. Try again.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveServiceAreas = (updatedServiceAreas: string[]) => {
|
||||
try {
|
||||
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||
service_areas: updatedServiceAreas,
|
||||
});
|
||||
|
||||
setVendor((prev) => ({
|
||||
...prev,
|
||||
service_areas: updatedServiceAreas,
|
||||
}));
|
||||
setMessage({ type: 'success', text: 'Service areas updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Error saving service area. Try again.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
|
||||
Vendor Profile Dashboard
|
||||
</Typography>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3, display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
{vendor.profile_picture && (
|
||||
<Avatar
|
||||
src={vendor.profile_picture}
|
||||
alt={vendor.business_name}
|
||||
sx={{ width: 80, height: 80 }}
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="h5">{vendor.business_name}</Typography>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{vendor.business_type}
|
||||
</Typography>
|
||||
{vendor.average_rating !== undefined && vendor.num_reviews !== undefined && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Rating value={vendor.average_rating} precision={0.1} readOnly />
|
||||
<Typography variant="body2" color="textSecondary" sx={{ ml: 1 }}>
|
||||
({vendor.num_reviews} reviews)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<VendorProfileCard
|
||||
vendor={vendor}
|
||||
onUpgrade={handleUpgradeSubscription}
|
||||
onSave={handleSaveVendorProfile}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<ServicesCard
|
||||
services={vendor.services}
|
||||
onSave={handleSaveServices}
|
||||
serviceAreas={vendor.service_areas}
|
||||
onSaveServiceAreas={handleSaveServiceAreas}
|
||||
/>
|
||||
|
||||
{/* You can add more sections here, e.g., for portfolio, reviews, etc. */}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default VendorProfile;
|
||||
@@ -0,0 +1,416 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
TextField,
|
||||
Box,
|
||||
Alert,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { VendorAPI, UserAPI } from '../types/api';
|
||||
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||
import { AutocompleteDataResponseAPI } from 'types';
|
||||
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||
import { extractLatLon } from 'utils';
|
||||
import { PlacePrediction } from './AddPropertyDialog';
|
||||
|
||||
interface VendorProfileCardProps {
|
||||
vendor: VendorAPI;
|
||||
onUpgrade: () => void;
|
||||
onSave: (updatedVendor: VendorAPI) => void;
|
||||
}
|
||||
|
||||
const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade, onSave }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedVendor, setEditedVendor] = useState<VendorAPI>(vendor);
|
||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
setEditedVendor(vendor); // Revert on cancel
|
||||
setFormErrors({});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e:
|
||||
| React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
| React.ChangeEvent<{ name?: string; value: unknown }>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setEditedVendor((prev) => ({
|
||||
...prev,
|
||||
[name as string]: value,
|
||||
// Handle nested user properties if you allow editing them here
|
||||
user:
|
||||
name === 'email' || name === 'first_name' || name === 'last_name'
|
||||
? { ...prev.user, [name]: value }
|
||||
: prev.user,
|
||||
}));
|
||||
|
||||
if (formErrors[name as string]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name as string];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (!editedVendor.business_name.trim()) {
|
||||
errors.business_name = 'Business name is required.';
|
||||
}
|
||||
if (!editedVendor.phone_number.trim()) {
|
||||
errors.phone_number = 'Phone number is required.';
|
||||
} else if (!/^\d{10}$/.test(editedVendor.phone_number.replace(/\D/g, ''))) {
|
||||
errors.phone_number = 'Invalid phone number format (10 digits).';
|
||||
}
|
||||
if (!editedVendor.address.trim()) {
|
||||
errors.address = 'Address is required.';
|
||||
}
|
||||
if (!editedVendor.city.trim()) {
|
||||
errors.city = 'City is required.';
|
||||
}
|
||||
if (!editedVendor.state.trim()) {
|
||||
errors.state = 'State is required.';
|
||||
}
|
||||
if (!editedVendor.zip_code.trim()) {
|
||||
errors.zip_code = 'Zip code is required.';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
onSave(editedVendor);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
//setNewProperty((prev) => ({ ...prev, [name]: value }));
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteInputChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||
// filter the data here
|
||||
} else {
|
||||
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||
'AutoComplete',
|
||||
{
|
||||
search: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
setAutocompleteOptions(
|
||||
data.map((item) => ({
|
||||
description: item.address,
|
||||
place_id: item.id,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log('we need more characters');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressAutocompleteChange = async (
|
||||
event: React.SyntheticEvent,
|
||||
value: PlacePrediction | null,
|
||||
) => {
|
||||
console.log('here we go', value);
|
||||
if (1) {
|
||||
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
|
||||
if (data.length > 0) {
|
||||
const item = data[0];
|
||||
const coordinates = extractLatLon(item.location);
|
||||
setEditedVendor((prev) => ({
|
||||
...prev,
|
||||
address: item.address,
|
||||
city: item.city,
|
||||
state: item.state,
|
||||
zip_code: item.zip,
|
||||
latitude: Number(coordinates.latitude),
|
||||
longitude: Number(coordinates.longitude),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// use the api here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Vendor Profile Information
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Box>
|
||||
<IconButton color="primary" onClick={handleSave}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton color="primary" onClick={handleEditToggle}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
name="first_name"
|
||||
value={editedVendor.user.first_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
name="last_name"
|
||||
value={editedVendor.user.last_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
name="email"
|
||||
type="email"
|
||||
value={editedVendor.user.email}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Business Name"
|
||||
name="business_name"
|
||||
value={editedVendor.business_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.business_name}
|
||||
helperText={formErrors.business_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth disabled={!isEditing}>
|
||||
<InputLabel id="business-type-label">Business Type</InputLabel>
|
||||
<Select
|
||||
labelId="business-type-label"
|
||||
id="business_type"
|
||||
name="business_type"
|
||||
value={editedVendor.business_type}
|
||||
label="Business Type"
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value="electrician">Electrician</MenuItem>
|
||||
<MenuItem value="carpenter">Carpenter</MenuItem>
|
||||
<MenuItem value="plumber">Plumber</MenuItem>
|
||||
<MenuItem value="inspector">Inspector</MenuItem>
|
||||
<MenuItem value="lendor">Lendor</MenuItem>
|
||||
<MenuItem value="other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Phone Number"
|
||||
name="phone_number"
|
||||
value={editedVendor.phone_number}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.phone_number}
|
||||
helperText={formErrors.phone_number}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
inputValue={editedVendor.adress}
|
||||
noOptionsText={'Type at least 3 characters'}
|
||||
onInputChange={handleAddressAutocompleteInputChange}
|
||||
onChange={handleAddressAutocompleteChange}
|
||||
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||
loading={autocompleteLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
label="Address"
|
||||
name="address"
|
||||
required
|
||||
error={!!formErrors.address}
|
||||
helperText={formErrors.address}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{autocompleteLoading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
name="city"
|
||||
value={editedVendor.city}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.city}
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
name="state"
|
||||
value={editedVendor.state}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.state}
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
name="zip_code"
|
||||
value={editedVendor.zip_code}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
required
|
||||
error={!!formErrors.zip_code}
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Business Description"
|
||||
name="description"
|
||||
multiline
|
||||
rows={3}
|
||||
value={editedVendor.description}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
name="website"
|
||||
value={editedVendor.website || ''}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Certifications (comma-separated)"
|
||||
name="certifications"
|
||||
value={editedVendor.certifications?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setEditedVendor((prev) => ({
|
||||
...prev,
|
||||
certifications: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s),
|
||||
}))
|
||||
}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
{vendor.user.tier === 'basic' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onUpgrade}
|
||||
sx={{ mt: 1 }}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Upgrade to Premium
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
{Object.keys(formErrors).length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Please correct the errors in the form.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorProfileCard;
|
||||
@@ -0,0 +1,474 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
Box,
|
||||
ImageList,
|
||||
ImageListItem,
|
||||
ImageListItemBar,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
|
||||
import { PropertiesAPI } from 'types';
|
||||
import MapComponent from '../../../../base/MapComponent';
|
||||
import FormattedListingText from 'components/base/FormattedListingText';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface PropertyDetailCardProps {
|
||||
property: PropertiesAPI;
|
||||
onSave: (updatedProperty: PropertiesAPI) => void;
|
||||
isPublicPage?: boolean;
|
||||
isOwnerView?: boolean; // True if the current user is the owner, allows editing
|
||||
onDelete?: (propertyId: number) => void;
|
||||
}
|
||||
|
||||
const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
property,
|
||||
onSave,
|
||||
isPublicPage,
|
||||
isOwnerView = false,
|
||||
onDelete = null,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isGenerating, setIsGernerating] = useState<boolean>(false);
|
||||
const [editedProperty, setEditedProperty] = useState<PropertiesAPI>(property);
|
||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
// Ensure latitude and longitude are defined, use defaults if not available
|
||||
const displayLat = Number(editedProperty.latitude) ?? 34.0522; // Default to LA for demo
|
||||
const displayLng = Number(editedProperty.longitude) ?? -118.2437; // Default to LA for demo
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
setEditedProperty(property); // Revert changes on cancel
|
||||
setFormErrors({});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditedProperty((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const numValue = parseFloat(value);
|
||||
setEditedProperty((prev) => ({
|
||||
...prev,
|
||||
[name]: isNaN(numValue) ? '' : numValue,
|
||||
}));
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeaturesChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setEditedProperty((prev) => ({
|
||||
...prev,
|
||||
features: value
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter((f) => f),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleViewPublicListing = () => {
|
||||
navigate(`/property/${property.id}/`);
|
||||
};
|
||||
|
||||
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
// In a real app, you'd upload these files to a server and get URLs back
|
||||
console.log(filesArray);
|
||||
filesArray.map((file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('Property', property.id.toString());
|
||||
|
||||
const response = axiosInstance.post('/properties/pictures/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log(response);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (!editedProperty.address.trim()) errors.address = 'Address is required.';
|
||||
if (!editedProperty.city.trim()) errors.city = 'City is required.';
|
||||
if (!editedProperty.state.trim()) errors.state = 'State is required.';
|
||||
if (!editedProperty.zip_code.trim()) errors.zip_code = 'Zip code is required.';
|
||||
if (editedProperty.sq_ft !== undefined && editedProperty.sq_ft <= 0)
|
||||
errors.sq_ft = 'Square footage must be positive.';
|
||||
if (editedProperty.num_bedrooms !== undefined && editedProperty.num_bedrooms < 0)
|
||||
errors.num_bedrooms = 'Bedrooms cannot be negative.';
|
||||
if (editedProperty.num_bathrooms !== undefined && editedProperty.num_bathrooms < 0)
|
||||
errors.num_bathrooms = 'Bathrooms cannot be negative.';
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
onSave(editedProperty);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isOwnerView && !isPublicPage && onDelete) {
|
||||
onDelete(property.id);
|
||||
}
|
||||
};
|
||||
|
||||
const generateDescription = async () => {
|
||||
setIsGernerating(true);
|
||||
|
||||
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
||||
console.log(response);
|
||||
setIsGernerating(false);
|
||||
|
||||
// TODO: toggle the update
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5" component="h2">
|
||||
{isEditing ? 'Edit Property Details' : property.address}
|
||||
</Typography>
|
||||
{isOwnerView &&
|
||||
!isPublicPage &&
|
||||
(isEditing ? (
|
||||
<Box>
|
||||
<IconButton color="primary" onClick={handleDelete}>
|
||||
<DeleteForeverIcon />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={handleSave}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton color="primary" onClick={handleEditToggle}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Property Address & Basic Info */}
|
||||
<Grid item xs={12}>
|
||||
{isEditing ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Address"
|
||||
name="address"
|
||||
value={editedProperty.address}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={!!formErrors.address}
|
||||
helperText={formErrors.address}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
name="city"
|
||||
value={editedProperty.city}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={!!formErrors.city}
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
name="state"
|
||||
value={editedProperty.state}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={!!formErrors.state}
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
name="zip_code"
|
||||
value={editedProperty.zip_code}
|
||||
onChange={handleChange}
|
||||
required
|
||||
error={!!formErrors.zip_code}
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Stack direction="row">
|
||||
<Typography variant="h6">
|
||||
{property.street}, {property.city}, {property.state} {property.zip_code}
|
||||
</Typography>
|
||||
{isOwnerView && !isPublicPage && (
|
||||
<Button onClick={handleViewPublicListing}>View Public Listing</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Pictures */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Pictures:
|
||||
</Typography>
|
||||
{editedProperty.pictures && editedProperty.pictures.length > 0 ? (
|
||||
<ImageList
|
||||
cols={editedProperty.pictures.length > 1 ? 2 : 1}
|
||||
rowHeight={164}
|
||||
sx={{ maxWidth: 500 }}
|
||||
>
|
||||
{editedProperty.pictures.map((item, index) => (
|
||||
<ImageListItem key={item.id}>
|
||||
<img
|
||||
srcSet={`${item.image}?w=164&h=164&fit=crop&auto=format 1x, ${item.image}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
||||
src={`${item.image}?w=164&h=164&fit=crop&auto=format`}
|
||||
alt={`Property image ${index + 1}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ImageListItemBar title={`Image ${index + 1}`} />
|
||||
</ImageListItem>
|
||||
))}
|
||||
</ImageList>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
No pictures available.
|
||||
</Typography>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<AddPhotoAlternateIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Add More Pictures
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handlePictureUpload}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Description & Stats */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Description:
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Stack direction="column">
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label="Description"
|
||||
name="description"
|
||||
value={editedProperty.description}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Button variant="contained" onClick={generateDescription} disabled={isGenerating}>
|
||||
Generate a description
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<FormattedListingText text={property.description} />
|
||||
// <Typography variant="body2" color="textSecondary">
|
||||
// {property.description || 'No description provided.'}
|
||||
// </Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mt: 2 }} gutterBottom>
|
||||
Stats:
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Sq Ft"
|
||||
name="sq_ft"
|
||||
type="number"
|
||||
value={editedProperty.sq_ft || ''}
|
||||
onChange={handleNumericChange}
|
||||
error={!!formErrors.sq_ft}
|
||||
helperText={formErrors.sq_ft}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Bedrooms"
|
||||
name="num_bedrooms"
|
||||
type="number"
|
||||
value={editedProperty.num_bedrooms || ''}
|
||||
onChange={handleNumericChange}
|
||||
error={!!formErrors.num_bedrooms}
|
||||
helperText={formErrors.num_bedrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Bathrooms"
|
||||
name="num_bathrooms"
|
||||
type="number"
|
||||
value={editedProperty.num_bathrooms || ''}
|
||||
onChange={handleNumericChange}
|
||||
error={!!formErrors.num_bathrooms}
|
||||
helperText={formErrors.num_bathrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Features (comma-separated)"
|
||||
name="features"
|
||||
value={editedProperty.features.join(', ') || ''}
|
||||
onChange={handleFeaturesChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Market Value"
|
||||
name="market_value"
|
||||
value={editedProperty.market_value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Amount"
|
||||
name="loan_amount"
|
||||
value={editedProperty.loan_amount}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Term (years)"
|
||||
name="loan_term"
|
||||
type="number"
|
||||
value={editedProperty.loan_term || ''}
|
||||
onChange={handleNumericChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Start Date"
|
||||
name="loan_start_date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={editedProperty.loan_start_date}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
|
||||
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
|
||||
<Typography variant="body2">
|
||||
Bathrooms: {property.num_bathrooms || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Features:{' '}
|
||||
{property.features && property.features.length > 0
|
||||
? property.features.join(', ')
|
||||
: 'None'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Market Value: ${property.market_value || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Amount: ${property.loan_amount || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Map */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Location on Map:
|
||||
</Typography>
|
||||
<MapComponent lat={displayLat} lng={displayLng} address={property.address} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
{Object.keys(formErrors).length > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Please correct the errors in the form.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyDetailCard;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Button, Box, CardMedia } from '@mui/material';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PropertiesAPI } from 'types';
|
||||
|
||||
interface PropertyListItemProps {
|
||||
property: PropertiesAPI;
|
||||
onViewDetails: (propertyId: number) => void; // For navigation in search page
|
||||
}
|
||||
|
||||
const PropertyListItem: React.FC<PropertyListItemProps> = ({ property, onViewDetails }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetailsClick = () => {
|
||||
// Navigate to the full detail page for this property
|
||||
navigate(`/property/${property.id}/?search=1`);
|
||||
};
|
||||
const value_price = property.listed_price ? property.listed_price : property.market_value;
|
||||
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{ display: 'flex', mb: 2, '&:hover': { boxShadow: 6 }, cursor: 'pointer' }}
|
||||
onClick={handleViewDetailsClick}
|
||||
>
|
||||
{property.pictures && property.pictures.length > 0 && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{ width: 150, height: 150, flexShrink: 0, objectFit: 'cover' }}
|
||||
image={property.pictures[0]}
|
||||
alt={property.address}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
|
||||
<CardContent sx={{ flex: '1 0 auto' }}>
|
||||
<Typography component="div" variant="h6">
|
||||
{property.address}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{property.city}, {property.state} {property.zip_code}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{property.num_bedrooms} Beds | {property.num_bathrooms} Baths | {property.sq_ft} Sq Ft
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 1 }}>
|
||||
{value_text}: <strong>${value_price}</strong>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pl: 1,
|
||||
pb: 1,
|
||||
pr: 2,
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button size="small" onClick={handleViewDetailsClick}>
|
||||
View Details
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyListItem;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Grid, Paper, Typography } from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
|
||||
interface SearchFilters {
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
minSqFt: number | '';
|
||||
maxSqFt: number | '';
|
||||
minBedrooms: number | '';
|
||||
maxBedrooms: number | '';
|
||||
minBathrooms: number | '';
|
||||
maxBathrooms: number | '';
|
||||
}
|
||||
|
||||
interface PropertySearchFiltersProps {
|
||||
onSearch: (filters: SearchFilters) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const initialFilters: SearchFilters = {
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
minSqFt: '',
|
||||
maxSqFt: '',
|
||||
minBedrooms: '',
|
||||
maxBedrooms: '',
|
||||
minBathrooms: '',
|
||||
maxBathrooms: '',
|
||||
};
|
||||
|
||||
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
|
||||
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const numValue = value === '' ? '' : parseFloat(value);
|
||||
setFilters(prev => ({ ...prev, [name]: numValue }));
|
||||
};
|
||||
|
||||
const handleSearchClick = () => {
|
||||
onSearch(filters);
|
||||
};
|
||||
|
||||
const handleClearClick = () => {
|
||||
setFilters(initialFilters);
|
||||
onClear();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Search Properties</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="Address Keyword" name="address" value={filters.address} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="City" name="city" value={filters.city} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="State" name="state" value={filters.state} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="Zip Code" name="zipCode" value={filters.zipCode} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Sq Ft" name="minSqFt" type="number" value={filters.minSqFt} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Sq Ft" name="maxSqFt" type="number" value={filters.maxSqFt} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Bedrooms" name="minBedrooms" type="number" value={filters.minBedrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Bedrooms" name="maxBedrooms" type="number" value={filters.maxBedrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Bathrooms" name="minBathrooms" type="number" value={filters.minBathrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Bathrooms" name="maxBathrooms" type="number" value={filters.maxBathrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
|
||||
Search
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertySearchFilters;
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Button,
|
||||
IconButton,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import { PropertiesAPI } from 'types';
|
||||
|
||||
interface PropertyStatusCardProps {
|
||||
property: PropertiesAPI;
|
||||
isOwner: boolean;
|
||||
onStatusChange?: () => void;
|
||||
onSavedPropertySave?: () => void;
|
||||
}
|
||||
|
||||
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
property,
|
||||
isOwner,
|
||||
onStatusChange,
|
||||
onSavedPropertySave,
|
||||
}) => {
|
||||
const getStatusColor = (status: PropertiesAPI['property_status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'contingent':
|
||||
return 'primary';
|
||||
case 'sold':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const timeSinceListed = (dateString: string) => {
|
||||
const listedDate = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - listedDate.getTime();
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||
return `${diffInDays} days`;
|
||||
};
|
||||
const getTimeOnMarketString = (status: PropertiesAPI['property_status'], listed_date: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return timeSinceListed(listed_date) + ' On Market';
|
||||
case 'pending':
|
||||
return timeSinceListed(listed_date) + ' On Market';
|
||||
case 'contingent':
|
||||
return timeSinceListed(listed_date) + ' On Market';
|
||||
case 'sold':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const timeOnMarketString: string = getTimeOnMarketString(
|
||||
property.property_status,
|
||||
property?.listed_date ? property?.listed_date : '',
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Property Status
|
||||
</Typography>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
|
||||
${property.listed_price ? property.listed_price : property.market_value}
|
||||
</Typography>
|
||||
{isOwner ? (
|
||||
<Select
|
||||
value={property.property_status}
|
||||
onChange={onStatusChange}
|
||||
displayEmpty
|
||||
variant="standard"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<MenuItem value="off_market" disabled={property.property_status === 'off_market'}>
|
||||
Off Market
|
||||
</MenuItem>
|
||||
<MenuItem value="active" disabled={property.property_status === 'active'}>
|
||||
Active
|
||||
</MenuItem>
|
||||
</Select>
|
||||
) : (
|
||||
<Chip
|
||||
label={property.property_status.toUpperCase()}
|
||||
color={getStatusColor(property.property_status)}
|
||||
sx={{ fontSize: '1rem', fontWeight: 'bold' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
|
||||
<Box display="flex" alignItems="center">
|
||||
<VisibilityIcon color="action" sx={{ mr: 1 }} />
|
||||
<Typography variant="body1">{property.views} Views</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center">
|
||||
{isOwner ? (
|
||||
<FavoriteIcon color="action" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<FavoriteIcon color="action" sx={{ mr: 1 }} onClick={onSavedPropertySave} />
|
||||
)}
|
||||
|
||||
<Typography variant="body1">{property.saves} Saves</Typography>
|
||||
</Box>
|
||||
{timeOnMarketString && (
|
||||
<Box display="flex" alignItems="center">
|
||||
<AccessTimeIcon color="action" sx={{ mr: 1 }} />
|
||||
<Typography variant="body1">{timeOnMarketString}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyStatusCard;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
47
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorCategoryCard.tsx
vendored
Normal file
47
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorCategoryCard.tsx
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
// src/components/VendorApp/VendorCategoryCard.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material';
|
||||
import { VendorCategory } from 'types';
|
||||
|
||||
|
||||
interface VendorCategoryCardProps {
|
||||
category: VendorCategory;
|
||||
onSelectCategory: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||
return (
|
||||
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={category.imageUrl}
|
||||
alt={category.name}
|
||||
/>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{category.description}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="body2">Vendors: {category.numVendors}</Typography>
|
||||
{category.categoryRating && (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Rating value={category.categoryRating} readOnly precision={0.5} size="small" />
|
||||
<Typography variant="caption" sx={{ ml: 0.5 }}>({category.categoryRating.toFixed(1)})</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
<Box sx={{ p: 2, pt: 0 }}>
|
||||
<Button size="small" onClick={() => onSelectCategory(category.id)}>
|
||||
View Vendors
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorCategoryCard;
|
||||
162
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorDetail.tsx
vendored
Normal file
162
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorDetail.tsx
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
// src/components/VendorApp/VendorDetail.tsx
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Box, Typography, Paper, Avatar, Rating, Grid, Button, Stack } from '@mui/material';
|
||||
|
||||
import PhoneIcon from '@mui/icons-material/Phone';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||
import { ConverationAPI, VendorItem } from 'types';
|
||||
import VendorMap from './VendorMap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import MapComponent from 'components/base/MapComponent';
|
||||
|
||||
interface VendorDetailProps {
|
||||
vendor: VendorItem;
|
||||
showMessageBtn: boolean;
|
||||
}
|
||||
|
||||
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
|
||||
const navigate = useNavigate();
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
const createMessage = () => {
|
||||
// First see if there is one already
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.get(
|
||||
`/conversations/?vendor=${vendor.id}`,
|
||||
);
|
||||
if (data === undefined) {
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.post(`/conversations/`, {
|
||||
property_owner: account?.id,
|
||||
vendor: vendor.id,
|
||||
});
|
||||
}
|
||||
|
||||
navigate('/messages');
|
||||
};
|
||||
if (!vendor) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
No vendor selected
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// increment the vendor view count
|
||||
const incrementVendorCount = async () => {
|
||||
if (showMessageBtn) {
|
||||
try {
|
||||
await axiosInstance.post(`/vendors/${vendor.id}/increment_view_count/?search=1`);
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
incrementVendorCount();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Paper elevation={3} sx={{ p: 2 }}>
|
||||
<Grid container sx={{ minHeight: '100%' }}>
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Avatar
|
||||
src={vendor.vendorImageUrl}
|
||||
alt={vendor.name}
|
||||
sx={{ width: 80, height: 80, mr: 2 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{vendor.name}
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Rating value={vendor.rating} readOnly precision={0.5} />
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
({vendor.rating.toFixed(1)} / 5)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{vendor.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
Contact Information
|
||||
</Typography>
|
||||
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
|
||||
<PhoneIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.phone}
|
||||
</Typography>
|
||||
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
|
||||
<EmailIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.email}
|
||||
</Typography>
|
||||
<Typography variant="body2" display="flex" alignItems="center">
|
||||
<LocationOnIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.address}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
Services Offered
|
||||
</Typography>
|
||||
<Box>
|
||||
{vendor.servicesOffered.map((service, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
|
||||
- {service}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
Service Areas
|
||||
</Typography>
|
||||
<Box>
|
||||
{vendor.serviceAreas.map((service, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
|
||||
- {service}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
{showMessageBtn && (
|
||||
<Button onClick={createMessage} disabled={accountLoading}>
|
||||
Message
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%', // Make sure the box takes available height
|
||||
minHeight: '400px', // Ensure a minimum height for the map
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px', // Optional: rounded corners
|
||||
overflow: 'hidden', // Ensures map doesn't overflow border radius
|
||||
}}
|
||||
>
|
||||
{vendor.latitude && vendor.longitude ? (
|
||||
<MapComponent lat={vendor.latitude} lng={vendor.longitude} address={vendor.address} />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Location data not available for this vendor.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorDetail;
|
||||
42
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorListItem.tsx
vendored
Normal file
42
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorListItem.tsx
vendored
Normal 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;
|
||||
57
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorMap.tsx
vendored
Normal file
57
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorMap.tsx
vendored
Normal 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;
|
||||
51
ditch-the-agent/src/contexts/AccountContext.tsx
Normal file
51
ditch-the-agent/src/contexts/AccountContext.tsx
Normal 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 }
|
||||
80
ditch-the-agent/src/contexts/ConversationContext.tsx
Normal file
80
ditch-the-agent/src/contexts/ConversationContext.tsx
Normal 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 }
|
||||
138
ditch-the-agent/src/contexts/MessageContext.tsx
Normal file
138
ditch-the-agent/src/contexts/MessageContext.tsx
Normal 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}
|
||||
208
ditch-the-agent/src/contexts/WebSocketContext.tsx
Normal file
208
ditch-the-agent/src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, createContext, useRef, useState, useContext, ReactNode } from 'react';
|
||||
import { AccountContext } from './AccountContext';
|
||||
import { AuthContext } from './AuthContext';
|
||||
|
||||
// ---
|
||||
// Define Types and Interfaces
|
||||
// ---
|
||||
|
||||
/**
|
||||
* Interface for the Account Context value.
|
||||
* Assumes account has at least an email property.
|
||||
*/
|
||||
interface IAccount {
|
||||
email: string | null;
|
||||
// Add other properties of the account object as needed
|
||||
// e.g., userId: string; name: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the Auth Context value.
|
||||
*/
|
||||
interface IAuthContext {
|
||||
authenticated: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the message data received from the WebSocket.
|
||||
* Adjust 'any' to a more specific type if the message structure is known.
|
||||
*/
|
||||
type WebSocketMessageData = string; // Assuming message.data is a string (e.g., JSON string)
|
||||
|
||||
/**
|
||||
* Type for a channel callback function.
|
||||
*/
|
||||
type ChannelCallback = (data: WebSocketMessageData) => void;
|
||||
|
||||
/**
|
||||
* Interface for the WebSocket Context value.
|
||||
* This defines the shape of the array returned by `useContext(WebSocketContext)`.
|
||||
*/
|
||||
interface IWebSocketContext {
|
||||
subscribe: (channel: string, callback: ChannelCallback) => void;
|
||||
unsubscribe: (channel: string) => void;
|
||||
socket: WebSocket | null;
|
||||
sendMessages: (messages: ChatMessage[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the WebSocketProvider component.
|
||||
*/
|
||||
interface WebSocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// ---
|
||||
// Create the WebSocket Context
|
||||
// ---
|
||||
|
||||
// Provide a default value that matches the IWebSocketContext interface.
|
||||
// This is used when a component tries to consume the context without a provider.
|
||||
const WebSocketContext = createContext<IWebSocketContext>({
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
socket: null,
|
||||
sendMessages: () => {},
|
||||
});
|
||||
|
||||
// ---
|
||||
// WebSocket Provider Component
|
||||
// ---
|
||||
|
||||
function WebSocketProvider({ children }: WebSocketProviderProps) {
|
||||
// Using useContext with explicit types for better type checking
|
||||
const { authenticated, loading } = useContext<IAuthContext>(AuthContext);
|
||||
const { account, setAccount } = useContext<{
|
||||
account: IAccount | null;
|
||||
setAccount: React.Dispatch<React.SetStateAction<IAccount | null>>;
|
||||
}>(AccountContext);
|
||||
|
||||
// useRef for WebSocket instance
|
||||
const ws = useRef<WebSocket | null>(null);
|
||||
|
||||
// useState for socket connection state
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||
|
||||
// useRef to store channel callbacks, mapping channel names to their callbacks
|
||||
const channels = useRef<{ [key: string]: ChannelCallback }>({});
|
||||
|
||||
// useState for the current active channel (though not directly used in the current logic for message dispatch)
|
||||
const [currentChannel, setCurrentChannel] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Subscribes a callback function to a specific WebSocket channel.
|
||||
* @param channel The name of the channel to subscribe to.
|
||||
* @param callback The function to be called when a message is received on this channel.
|
||||
*/
|
||||
const subscribe = (channel: string, callback: ChannelCallback) => {
|
||||
setCurrentChannel(channel); // This seems to track the last subscribed channel globally, not per-message.
|
||||
channels.current[channel] = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unsubscribes a callback from a specific WebSocket channel.
|
||||
* @param channel The name of the channel to unsubscribe from.
|
||||
*/
|
||||
const unsubscribe = (channel: string) => {
|
||||
delete channels.current[channel];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a message over the WebSocket connection.
|
||||
* Handles both text messages and file uploads (by converting file to base64).
|
||||
* @param message The text message to send.
|
||||
* @param account_iod The ID of the conversation this message belongs to.
|
||||
*/
|
||||
const sendMessages = (messages: ChatMessage[]) => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
const data = {
|
||||
messages: messages,
|
||||
};
|
||||
socket.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.log('Error sending message. WebSocket is not open');
|
||||
}
|
||||
};
|
||||
|
||||
// ---
|
||||
// WebSocket Initialization and Event Handling
|
||||
// ---
|
||||
useEffect(() => {
|
||||
if (account?.email) {
|
||||
// Ensure account and email exist before attempting to connect
|
||||
// Close any existing connection before creating a new one
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(
|
||||
`ws://127.0.0.1:8010/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
||||
);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setSocket(ws.current);
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.current.onclose = () => {
|
||||
setSocket(null);
|
||||
console.log('WebSocket disconnected');
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event: MessageEvent) => {
|
||||
const data: WebSocketMessageData = event.data;
|
||||
// The original logic assumes a single active chat channel is always present
|
||||
// and iterates Object.entries(channels.current)[0][0] to get its key.
|
||||
// This might be problematic if multiple channels are intended to be active
|
||||
// or if messages don't always correspond to the first subscribed channel.
|
||||
// For now, retaining the original logic, but consider refining based on backend message structure.
|
||||
const chatChannelKey = Object.keys(channels.current)[0];
|
||||
|
||||
if (chatChannelKey && channels.current[chatChannelKey]) {
|
||||
// Call the callback associated with the identified channel
|
||||
channels.current[chatChannelKey](data);
|
||||
} else {
|
||||
console.log('No active chat channel subscribed or message format unexpected.');
|
||||
// Potentially handle generic messages or log the unhandled message
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup function: Closes the WebSocket connection when the component unmounts
|
||||
// or when the 'account' dependency changes, triggering a re-run of this effect.
|
||||
return () => {
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
console.log('WebSocket cleaned up');
|
||||
}
|
||||
};
|
||||
} else if (!loading && !authenticated) {
|
||||
// If not authenticated and not loading, ensure WebSocket is closed if it was open.
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
setSocket(null);
|
||||
}
|
||||
}
|
||||
}, [account, authenticated, loading]); // Dependencies: reconnect if account or auth status changes
|
||||
|
||||
// ---
|
||||
// Render the Provider
|
||||
// ---
|
||||
return (
|
||||
<WebSocketContext.Provider value={{ subscribe, unsubscribe, socket, sendMessages }}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { WebSocketContext, WebSocketProvider };
|
||||
42
ditch-the-agent/src/data/attorney-nav-items.ts
Normal file
42
ditch-the-agent/src/data/attorney-nav-items.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NavItem } from 'types';
|
||||
|
||||
const attorneyNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Profile',
|
||||
path: '/profile',
|
||||
icon: 'ph:user-circle',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: '/conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Documents',
|
||||
path: '/documents',
|
||||
icon: 'ph:file',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default attorneyNavItems;
|
||||
113
ditch-the-agent/src/data/basic-nav-items.ts
Normal file
113
ditch-the-agent/src/data/basic-nav-items.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NavItem } from 'types';
|
||||
|
||||
const basicNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Profile',
|
||||
path: '/profile',
|
||||
icon: 'ph:user-circle',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property/search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Education',
|
||||
path: '/education',
|
||||
icon: 'ph:student',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Vendors',
|
||||
path: '/vendors',
|
||||
icon: 'ph:storefront',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Messages',
|
||||
path: '',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Offers',
|
||||
path: 'offers',
|
||||
icon: 'ph:certificate',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: 'conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
path: '/tools',
|
||||
icon: 'ph:toolbox',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Mortgage Calculator',
|
||||
path: '/mortgage-calculator',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Amortization Table',
|
||||
path: '/amoritization-table',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Home Affordability',
|
||||
path: '/home-affordability',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Net Terms Sheet',
|
||||
path: '/net-terms-sheet',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default basicNavItems;
|
||||
406
ditch-the-agent/src/data/mock_autocomplete_results.ts
Normal file
406
ditch-the-agent/src/data/mock_autocomplete_results.ts
Normal 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',
|
||||
};
|
||||
682
ditch-the-agent/src/data/mock_property_search.ts
Normal file
682
ditch-the-agent/src/data/mock_property_search.ts
Normal 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,
|
||||
};
|
||||
@@ -1,11 +1,4 @@
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
active: boolean;
|
||||
collapsible: boolean;
|
||||
sublist?: NavItem[];
|
||||
}
|
||||
import { NavItem } from 'types';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
@@ -21,73 +14,19 @@ const navItems: NavItem[] = [
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Sales',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
path: 'authentication',
|
||||
icon: 'f7:exclamationmark-shield-fill',
|
||||
title: 'Profile',
|
||||
path: '/profile',
|
||||
icon: 'ph:user-circle',
|
||||
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',
|
||||
title: 'Search',
|
||||
path: '/property/search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
@@ -101,11 +40,74 @@ const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Vendors',
|
||||
path: '/vendors',
|
||||
icon: 'ph:toolbox',
|
||||
icon: 'ph:storefront',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Messages',
|
||||
path: '',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Offers',
|
||||
path: 'offers',
|
||||
icon: 'ph:certificate',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: 'conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
path: '/tools',
|
||||
icon: 'ph:toolbox',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Mortgage Calculator',
|
||||
path: 'mortgage-calculator',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Amortization Table',
|
||||
path: 'amoritization-table',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Home Affordability',
|
||||
path: 'home-affordability',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Net Terms Sheet',
|
||||
path: 'net-terms-sheet',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default navItems;
|
||||
|
||||
42
ditch-the-agent/src/data/vendor-nav-items.ts
Normal file
42
ditch-the-agent/src/data/vendor-nav-items.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NavItem } from 'types';
|
||||
|
||||
const vendorNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Profile',
|
||||
path: '/profile',
|
||||
icon: 'ph:user-circle',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: '/conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Bids',
|
||||
path: '/vendor-bids',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default vendorNavItems;
|
||||
@@ -4,7 +4,7 @@ const Footer = () => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={{ xs: 'center', md: 'flex-end' }}
|
||||
justifyContent={{ xs: 'center' }}
|
||||
ml={{ xs: 3.75, lg: 34.75 }}
|
||||
mr={3.75}
|
||||
my={3.75}
|
||||
@@ -14,7 +14,7 @@ const Footer = () => {
|
||||
href="https://ditchtheagent.com/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ color: 'text.primary', '&:hover': { color: 'primary.main' } }}
|
||||
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
|
||||
>
|
||||
Ditch The Agent
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactElement, useContext } from 'react';
|
||||
import {
|
||||
Link,
|
||||
List,
|
||||
@@ -12,10 +12,52 @@ import {
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import navItems from 'data/nav-items';
|
||||
|
||||
import NavButton from './NavButton';
|
||||
import { axiosInstance } from '../../../axiosApi.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AuthContext } from 'contexts/AuthContext';
|
||||
import { AccountContext } from 'contexts/AccountContext.js';
|
||||
|
||||
import navItems from 'data/nav-items';
|
||||
import vendorNavItems from 'data/vendor-nav-items.js';
|
||||
import basicNavItems from 'data/basic-nav-items.js';
|
||||
import { NavItem } from 'types.js';
|
||||
import attorneyNavItems from 'data/attorney-nav-items.js';
|
||||
|
||||
const Sidebar = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { authenticated, setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
let nav_items: NavItem[] = [];
|
||||
if (account && !accountLoading) {
|
||||
if (account.user_type === 'property_owner') {
|
||||
if (account.tier === 'premium') {
|
||||
nav_items = navItems;
|
||||
} else {
|
||||
nav_items = basicNavItems;
|
||||
}
|
||||
} else if (account.user_type === 'vendor') {
|
||||
nav_items = vendorNavItems;
|
||||
} else if (account.user_type === 'attorney') {
|
||||
nav_items = attorneyNavItems;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/logout/', {
|
||||
refresh_token: localStorage.getItem('refresh_token'),
|
||||
});
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
axiosInstance.defaults.headers['Authorization'] = null;
|
||||
setAuthentication(false);
|
||||
} finally {
|
||||
navigate('/authentication/login/');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
justifyContent="space-between"
|
||||
@@ -66,7 +108,7 @@ const Sidebar = (): ReactElement => {
|
||||
width: 178,
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, index) => (
|
||||
{nav_items.map((navItem, index) => (
|
||||
<NavButton key={index} navItem={navItem} Link={Link} />
|
||||
))}
|
||||
</List>
|
||||
@@ -85,8 +127,8 @@ const Sidebar = (): ReactElement => {
|
||||
LinkComponent={Link}
|
||||
href="/"
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
color: 'primary.main',
|
||||
backgroundColor: 'background.default',
|
||||
color: 'common.white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'common.white',
|
||||
@@ -97,7 +139,7 @@ const Sidebar = (): ReactElement => {
|
||||
<ListItemIcon>
|
||||
<IconifyIcon icon="ri:logout-circle-line" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Log out</ListItemText>
|
||||
<ListItemText onClick={handleSignOut}>Log out</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
@@ -86,28 +86,6 @@ const Topbar = ({ handleDrawerToggle }: TopbarProps): ReactElement => {
|
||||
<Typography variant="h5" component="h5">
|
||||
{pathname === '/' ? 'Dashboard' : title}
|
||||
</Typography>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
placeholder="Search..."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ width: 24, height: 24 }}>
|
||||
<IconifyIcon icon="mdi:search" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ maxWidth: 330 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={{ xs: 1, sm: 1.75 }}>
|
||||
<LanguageDropdown />
|
||||
<IconButton color="inherit" centerRipple sx={{ bgcolor: 'inherit', p: 0.75 }}>
|
||||
<Badge badgeContent={1} color="primary">
|
||||
<IconifyIcon icon="carbon:notification-filled" width={24} height={24} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<AccountDropdown />
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PropsWithChildren, ReactElement, useState } from 'react';
|
||||
import { Box, Drawer, Stack, Toolbar } from '@mui/material';
|
||||
|
||||
|
||||
|
||||
import Sidebar from 'layouts/main-layout/Sidebar/Sidebar';
|
||||
import Topbar from 'layouts/main-layout/Topbar/Topbar';
|
||||
import Footer from './Footer';
|
||||
@@ -78,9 +76,9 @@ const MainLayout = ({ children }: PropsWithChildren): ReactElement => {
|
||||
pt: 12,
|
||||
width: 1,
|
||||
pb: 0,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
|
||||
{children}
|
||||
<FloatingChatButton />
|
||||
</Toolbar>
|
||||
|
||||
@@ -7,23 +7,22 @@ import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import BreakpointsProvider from 'providers/BreakpointsProvider.tsx';
|
||||
import router from 'routes/router.tsx';
|
||||
import { AuthProvider } from 'contexts/AuthContext.tsx';
|
||||
import { AccountProvider } from 'contexts/AccountContext.tsx';
|
||||
import { WebSocketProvider } from 'contexts/WebSocketContext.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
|
||||
|
||||
<ThemeProvider theme={theme}>
|
||||
<BreakpointsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BreakpointsProvider>
|
||||
<AuthProvider>
|
||||
|
||||
|
||||
<CssBaseline />
|
||||
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</BreakpointsProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
<AccountProvider>
|
||||
<WebSocketProvider>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</WebSocketProvider>
|
||||
</AccountProvider>
|
||||
</AuthProvider>
|
||||
</BreakpointsProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
75
ditch-the-agent/src/pages/Bids/Bids.tsx
Normal file
75
ditch-the-agent/src/pages/Bids/Bids.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
|
||||
import { BidAPI, PropertiesAPI } from 'types';
|
||||
import { AddBidDialog } from 'components/sections/dashboard/Home/Bids/AddBidDialog';
|
||||
import { BidCard } from 'components/sections/dashboard/Home/Bids/BidCard';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
const BidsPage: React.FC = () => {
|
||||
const [bids, setBids] = useState<BidAPI[]>([]);
|
||||
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||
const [openAddBidDialog, setOpenAddBidDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
// You'll need an endpoint to get all properties for the current owner
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||
setProperties(data);
|
||||
|
||||
// You'll need an endpoint to get all bids for the current owner's properties
|
||||
const { data: bidData }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||
setBids(bidData);
|
||||
};
|
||||
|
||||
const handleDeleteBid = async (bidId: number) => {
|
||||
try {
|
||||
await axios.delete(`/api/bids/${bidId}/`);
|
||||
fetchData(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error('Failed to delete bid', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
My Property Bids
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => setOpenAddBidDialog(true)}>
|
||||
Add New Bid
|
||||
</Button>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||
{bids.map((bid) => (
|
||||
<Grid item xs={12} key={bid.id}>
|
||||
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<AddBidDialog
|
||||
open={openAddBidDialog}
|
||||
onClose={() => setOpenAddBidDialog(false)}
|
||||
properties={properties}
|
||||
onBidAdded={fetchData}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default BidsPage;
|
||||
@@ -1,29 +1,161 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { EducationInfoCards } from 'components/sections/dashboard/Home/Education/EducationInfo';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import {axiosInstance} from '../../axiosApi'
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
|
||||
import { GenericCategory, GenericItem, VideoAPI, VideoCategory, VideoItem, VideoProgressAPI } from 'types';
|
||||
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
|
||||
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
|
||||
|
||||
|
||||
|
||||
const Education = (): ReactElement => {
|
||||
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
|
||||
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
|
||||
|
||||
// Simulate fetching data from backend
|
||||
let fetchedVideos: VideoItem[] = []
|
||||
useEffect(() => {
|
||||
// In a real app, you'd make an API call here
|
||||
const fetchVideos = async () => {
|
||||
// Replace with your actual API call
|
||||
try{
|
||||
|
||||
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/')
|
||||
if(data.length > 0){
|
||||
fetchedVideos = data.map(item => {
|
||||
console.log(item)
|
||||
return {
|
||||
id: String(item.video.id),
|
||||
progress_id: item.id,
|
||||
name: item.video.title,
|
||||
description: item.video.description,
|
||||
category: item.video.category.name,
|
||||
categoryId: item.video.category.id,
|
||||
status: item.status,
|
||||
progress: item.progress,
|
||||
videoUrl: item.video.link,
|
||||
duration: item.video.duration,
|
||||
}
|
||||
})
|
||||
setAllVideos(fetchedVideos)
|
||||
}
|
||||
|
||||
}catch (error){
|
||||
console.log('there was an error', error)
|
||||
}
|
||||
const categoryMap = new Map<string, {name: string; total: number; completed: number; description: string; imageUrl: string; }>();
|
||||
|
||||
// Populate category details (you might hardcode descriptions/images or fetch them)
|
||||
// For demonstration, let's assume a default image and generate a description
|
||||
const defaultCategoryImages: { [key: string]: string } = {
|
||||
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
|
||||
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
|
||||
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
fetchedVideos.forEach(video => {
|
||||
const categoryId = video.categoryId;
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
let categoryName = video.category;
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
description: `Explore ${video.category} concepts and build your skills.`,
|
||||
imageUrl: defaultCategoryImages[video.category] || 'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
});
|
||||
}
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.total += 1;
|
||||
if (video.status === 'completed') {
|
||||
categoryData.completed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(([id, data]) => ({
|
||||
id, // id is the category name here
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
imageUrl: data.imageUrl,
|
||||
totalVideos: data.total,
|
||||
completedVideos: data.completed,
|
||||
categoryProgress: (data.total > 0) ? (data.completed / data.total) * 100 : 0,
|
||||
}));
|
||||
setVideoCategories(processedCategories);
|
||||
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// const handleSelectCategory = (categoryName: string) => {
|
||||
// setSelectedCategory(categoryName);
|
||||
// };
|
||||
|
||||
// const handleBackToCategories = () => {
|
||||
// setSelectedCategory(null);
|
||||
// };
|
||||
|
||||
return(
|
||||
<Grid
|
||||
container
|
||||
component="main"
|
||||
columns={12}
|
||||
spacing={3.75}
|
||||
flexGrow={1}
|
||||
pt={4.375}
|
||||
pr={1.875}
|
||||
pb={0}
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
pl: { xs: 3.75, lg: 0 },
|
||||
}}
|
||||
>
|
||||
<Grid xs={12} md={12}>
|
||||
<EducationInfoCards />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<DashboardTemplate<VideoCategory, VideoItem>
|
||||
pageTitle="Educational Videos"
|
||||
data={{ categories: videoCategories, items: allVideos }}
|
||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||
<CategoryGridTemplate
|
||||
categories={categories}
|
||||
onSelectCategory={(id) => onSelectCategory(id)}
|
||||
renderCategoryCard={(category, onSelect) => (
|
||||
<VideoCategoryCard category={category as VideoCategory} onSelectCategory={onSelect} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||
<ItemListDetailTemplate
|
||||
category={selectedCategory}
|
||||
items={itemsInSelectedCategory}
|
||||
onBack={onBack}
|
||||
renderListItem={(item, isSelected, onSelect) => (
|
||||
<VideoListItem video={item as VideoItem} isSelected={isSelected}
|
||||
onSelect={() => {
|
||||
console.log('selecting')
|
||||
onSelect(item.id)}
|
||||
} />
|
||||
)}
|
||||
renderItemDetail={(item) => (
|
||||
<VideoPlayer video={item as VideoItem} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
)
|
||||
// (return(
|
||||
// <Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
// <Typography variant="h4" component="h1" gutterBottom>
|
||||
// Educational Videos
|
||||
// </Typography>
|
||||
|
||||
// {selectedCategory ? (
|
||||
// <VideoPlayerPage
|
||||
// categoryName={selectedCategory}
|
||||
// videos={videos.filter(video => video.category === selectedCategory)}
|
||||
// onBack={handleBackToCategories}
|
||||
// // You'll need to pass functions for updating video progress back to Dashboard
|
||||
// // For simplicity, we'll assume updates happen on the backend or in a global state
|
||||
// />
|
||||
// ) : (
|
||||
// <CategoryGrid categories={categories} onSelectCategory={handleSelectCategory} />
|
||||
// )}
|
||||
// </Container>
|
||||
// )
|
||||
}
|
||||
|
||||
export default Education;
|
||||
362
ditch-the-agent/src/pages/Messages/Messages.tsx
Normal file
362
ditch-the-agent/src/pages/Messages/Messages.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
|
||||
import {
|
||||
ConverationAPI,
|
||||
ConversationItem,
|
||||
GenericCategory,
|
||||
MessagesAPI,
|
||||
VendorCategory,
|
||||
VendorItem,
|
||||
} from 'types';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
List,
|
||||
Grid,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Avatar,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
|
||||
import { formatTimestamp } from 'utils';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
senderId: 'owner' | 'vendor'; // 'owner' represents the property owner, 'vendor' is the other party
|
||||
content: string;
|
||||
timestamp: string; // ISO string for date/time
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: number;
|
||||
withName: string; // Name of the vendor or property owner
|
||||
lastMessageSnippet: string;
|
||||
lastMessageTimestamp: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const Messages = (): ReactElement => {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
||||
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
||||
const { account } = useContext(AccountContext);
|
||||
|
||||
// Auto-scroll to the bottom of the messages when they update
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<ConverationAPI[]> =
|
||||
await axiosInstance.get('/conversations/');
|
||||
console.log(data);
|
||||
if (data.length > 0) {
|
||||
console.log(data);
|
||||
const fetchedConversations: Conversation[] = data.map((item) => {
|
||||
const lastMessageSnippet: string =
|
||||
item.messages.length > 0 ? item.messages[item.messages.length - 1].text : '';
|
||||
const messages: Message[] = item.messages.map((message) => {
|
||||
return {
|
||||
id: message.id,
|
||||
content: message.text,
|
||||
timestamp: message.timestamp,
|
||||
senderId: message.sender === item.property_owner.user.id ? 'owner' : 'vendor',
|
||||
};
|
||||
});
|
||||
console.log(messages);
|
||||
return {
|
||||
id: item.id,
|
||||
withName: item.vendor.business_name,
|
||||
lastMessageTimestamp: item.updated_at,
|
||||
lastMessageSnippet: lastMessageSnippet,
|
||||
messages: messages,
|
||||
};
|
||||
});
|
||||
console.log(fetchedConversations);
|
||||
setConversations(fetchedConversations);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
fetchConversations();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
|
||||
|
||||
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
|
||||
|
||||
// Handle sending a new message
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessageContent.trim() || !selectedConversationId) {
|
||||
return; // Don't send empty messages or if no conversation is selected
|
||||
}
|
||||
|
||||
// send the message to the backend
|
||||
try {
|
||||
const { data }: AxiosResponse<MessagesAPI> = await axiosInstance.post(
|
||||
`/conversations/${selectedConversationId}/messages/`,
|
||||
{
|
||||
sender: account?.id,
|
||||
text: newMessageContent.trim(),
|
||||
conversation: selectedConversationId,
|
||||
},
|
||||
);
|
||||
console.log(data);
|
||||
const newMessage: Message = {
|
||||
id: data.id,
|
||||
senderId: 'owner', // Assuming the current user is the 'owner'
|
||||
content: newMessageContent.trim(),
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
|
||||
setConversations((prevConversations) =>
|
||||
prevConversations.map((conv) =>
|
||||
conv.id === selectedConversationId
|
||||
? {
|
||||
...conv,
|
||||
messages: [...conv.messages, newMessage],
|
||||
lastMessageSnippet: newMessage.content,
|
||||
lastMessageTimestamp: newMessage.timestamp,
|
||||
}
|
||||
: conv,
|
||||
),
|
||||
);
|
||||
setNewMessageContent(''); // Clear the input field
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||
>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
{/* Left Panel: Conversation List */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={4}
|
||||
sx={{
|
||||
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
|
||||
<Stack direction="row" sx={{ width: '100%' }}>
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
Conversations
|
||||
</Typography>
|
||||
{account?.user_type === 'property_owner' && (
|
||||
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
|
||||
New Conversation
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
{conversations.length === 0 ? (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
<ChatBubbleOutlineIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography>No conversations yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
conversations
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastMessageTimestamp).getTime() -
|
||||
new Date(a.lastMessageTimestamp).getTime(),
|
||||
)
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
selected={selectedConversationId === conv.id}
|
||||
onClick={() => setSelectedConversationId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{conv.withName}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1, pr: 1 }}
|
||||
>
|
||||
{conv.lastMessageSnippet}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimestamp(conv.lastMessageTimestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Conversation Detail */}
|
||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Conversation Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: 'purple.200' }}>
|
||||
{selectedConversation.withName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</Avatar>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{selectedConversation.withName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
{selectedConversation.messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: message.senderId === 'owner' ? 'flex-end' : 'flex-start',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: '75%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: message.senderId === 'owner' ? 'purple.200' : 'lightblue.50',
|
||||
color: 'grey.800',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'medium', mb: 0.5 }}>
|
||||
{message.senderId === 'owner' ? 'You' : selectedConversation.withName}
|
||||
</Typography>
|
||||
<Typography variant="body1">{message.content}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', textAlign: 'right', mt: 0.5 }}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<div ref={messagesEndRef} /> {/* Scroll target */}
|
||||
</Box>
|
||||
|
||||
{/* Message Input */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Type your message..."
|
||||
value={newMessageContent}
|
||||
onChange={(e) => setNewMessageContent(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSendMessage}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 3,
|
||||
color: 'grey.500',
|
||||
}}
|
||||
>
|
||||
<ChatBubbleOutlineIcon sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6">Select a conversation to view messages</Typography>
|
||||
<Typography variant="body2">
|
||||
Click on a conversation from the left panel to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
340
ditch-the-agent/src/pages/Offers/Offers.tsx
Normal file
340
ditch-the-agent/src/pages/Offers/Offers.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
|
||||
import { ConverationAPI, ConversationItem, GenericCategory, MessagesAPI, OfferAPI, VendorCategory, VendorItem } from 'types';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||
import {axiosInstance} from '../../axiosApi'
|
||||
import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack, Accordion, AccordionActions, AccordionSummary, AccordionDetails, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
|
||||
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
|
||||
import { formatTimestamp } from 'utils';
|
||||
import CreateOfferDialog from 'components/sections/dashboard/Home/Offer/CreateOfferDialog';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
|
||||
interface OfferDetail {
|
||||
id: number;
|
||||
senderId: 'owner' | 'vendor'; // 'owner' represents the property owner, 'vendor' is the other party
|
||||
content: string;
|
||||
timestamp: string; // ISO string for date/time
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
id: number;
|
||||
sender: string; // the name of the person who sent it
|
||||
sender_id: number;
|
||||
property_id: number;
|
||||
address: string;
|
||||
status: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'countered';
|
||||
is_active: boolean;
|
||||
lastMessageTimestamp: string;
|
||||
market_value: string;
|
||||
offer_value: string;
|
||||
|
||||
}
|
||||
|
||||
type submitOfferProps = {
|
||||
offer_id: number,
|
||||
sender_id: number,
|
||||
property_id: number,
|
||||
}
|
||||
|
||||
const Offers = (): ReactElement => {
|
||||
const [offers, setOffers] = useState<Offer[]>([]);
|
||||
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
|
||||
const {account} = useContext(AccountContext)
|
||||
|
||||
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const closeDialog = () => {
|
||||
setShowDialog(false);
|
||||
}
|
||||
const createOffer = async (property_id: number) => {
|
||||
console.log(account)
|
||||
if(account)
|
||||
{
|
||||
console.log({
|
||||
user: account.id,
|
||||
property: property_id,
|
||||
|
||||
|
||||
})
|
||||
response = await axiosInstance.post(`/offers/`,
|
||||
{
|
||||
user: account.id,
|
||||
property: property_id,
|
||||
|
||||
|
||||
})
|
||||
setShowDialog(false);
|
||||
|
||||
|
||||
setShowDialog(false)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const submitOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
||||
response = await axiosInstance.put(`/offers/${offer_id}/`,
|
||||
{
|
||||
user: sender_id,
|
||||
property: property_id,
|
||||
status:'submitted'
|
||||
|
||||
})
|
||||
console.log(response)
|
||||
|
||||
// TODO: update the selectedOffer' status
|
||||
const updatedOffers: Offer[] = offers.map(item => ({
|
||||
...item, // Spread operator to copy existing properties
|
||||
status: 'submitted'
|
||||
}));
|
||||
setOffers(updatedOffers);
|
||||
}
|
||||
|
||||
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
||||
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchOffers = async () => {
|
||||
try{
|
||||
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/')
|
||||
console.log(data)
|
||||
if (data.length > 0){
|
||||
console.log(data)
|
||||
const fetchedOffers: Offer[] = data.map(item => {
|
||||
|
||||
console.log(item)
|
||||
return {
|
||||
id: item.id,
|
||||
sender: item.user.first_name + " " + item.user.last_name,
|
||||
status: item.status,
|
||||
address: item.property.address,
|
||||
is_active: item.is_active,
|
||||
lastMessageTimestamp: item.updated_at,
|
||||
market_value: item.property.market_value,
|
||||
offer_value: '100000',
|
||||
sender_id: item.user.id,
|
||||
property_id: item.property.id
|
||||
|
||||
}
|
||||
})
|
||||
console.log(fetchedOffers)
|
||||
setOffers(fetchedOffers);
|
||||
|
||||
}
|
||||
|
||||
}catch(error){
|
||||
|
||||
}
|
||||
}
|
||||
fetchOffers();
|
||||
}, [])
|
||||
|
||||
type offerChoice = 'accept' | 'counter' | 'reject';
|
||||
|
||||
const handleOffer = async (choice: offerChoice) => {
|
||||
console.log(choice)
|
||||
}
|
||||
|
||||
const selectedOffer = offers.find(
|
||||
(conv) => conv.id === selectedOfferId
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
{/* Left Panel: Offer List */}
|
||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||
<Stack direction="row" sx={{width:'100%'}}>
|
||||
|
||||
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
Offers
|
||||
</Typography>
|
||||
<Button
|
||||
variant='contained'
|
||||
color='primary'
|
||||
sx={{ml:'auto'}}
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Create Offer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
{offers.length === 0 ? (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography>No offers submited yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
offers
|
||||
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
selected={selectedOfferId === conv.id}
|
||||
onClick={() => setSelectedOfferId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{conv.sender}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
|
||||
{conv.address}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimestamp(conv.lastMessageTimestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Offer Detail */}
|
||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedOffer ? (
|
||||
<>
|
||||
{/* Offer Header */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'purple.200'}}>
|
||||
{selectedOffer.sender.split(' ').map(n => n[0]).join('')}
|
||||
</Avatar>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{selectedOffer.sender}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
{/* add the offer details here */}
|
||||
<Accordion>
|
||||
<AccordionSummary>
|
||||
Offer for {selectedOffer.address}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>
|
||||
Offer Price: <strong>{selectedOffer.offer_value}</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Waived Inspection: <strong>No</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Closing date: <strong>90 days</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Status: <strong>{selectedOffer.status}</strong>
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
<AccordionActions>
|
||||
|
||||
{selectedOffer.status === 'submitted' ? (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => handleOffer('accept')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => handleOffer('counter')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Counter
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={ async() => handleOffer('reject')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
): (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => withdrawOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
||||
endIcon={<DeleteForeverIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Withdraw Offer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
</AccordionActions>
|
||||
</Accordion>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Message Input */}
|
||||
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6">Select an offer to view</Typography>
|
||||
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<CreateOfferDialog showDialog={showDialog} createOffer={createOffer} closeDialog={closeDialog} />
|
||||
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default Offers;
|
||||
42
ditch-the-agent/src/pages/Profile/Profile.tsx
Normal file
42
ditch-the-agent/src/pages/Profile/Profile.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Container, Typography, Box, Button, Divider, Paper, Alert, Grid } from '@mui/material';
|
||||
import { PropertiesAPI, UserAPI } from 'types';
|
||||
import AddPropertyDialog from 'components/sections/dashboard/Home/Profile/AddPropertyDialog';
|
||||
import PropertyCard from 'components/sections/dashboard/Home/Profile/PropertyCard.';
|
||||
import ProfileCard from 'components/sections/dashboard/Home/Profile/ProfileCard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import PropertyOwnerProfile from 'components/sections/dashboard/Home/Profile/PropertyOwnerProfile';
|
||||
import VendorProfile from 'components/sections/dashboard/Home/Profile/VendorProfile';
|
||||
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
|
||||
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
|
||||
import AttorneyProfile from 'components/sections/dashboard/Home/Profile/AttorneyProfile';
|
||||
|
||||
export type ProfileProps = {
|
||||
account: UserAPI;
|
||||
};
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
if (accountLoading) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
if (account.user_type === 'property_owner') {
|
||||
return <PropertyOwnerProfile account={account} />;
|
||||
} else if (account.user_type === 'vendor') {
|
||||
return <VendorProfile account={account} />;
|
||||
} else if (account.user_type === 'attorney') {
|
||||
return <AttorneyProfile account={account} />;
|
||||
} else if (account.user_type === 'real_estate_agent') {
|
||||
return <>TODO</>;
|
||||
//return (<VendorProfile account={account} />)
|
||||
}
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -1,60 +1,398 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
|
||||
import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
|
||||
import PhotoGalleryCard from 'components/sections/dashboard/Home/Property/PhotoGalleryCard';
|
||||
import MarketStatistics from 'components/sections/dashboard/Home/Property/MarketStatistics';
|
||||
import PropertyListingCard from 'components/sections/dashboard/Home/Property/PropertyListingCard';
|
||||
import LoanDetailsCard from 'components/sections/dashboard/Home/Property/LoanDetailsCard';
|
||||
import PropertyValueGraphCard from 'components/sections/dashboard/Home/Property/PropertyValueGraphCard';
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Typography, Box, Grid, Alert } from '@mui/material';
|
||||
import { PropertiesAPI } from 'types';
|
||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||
|
||||
const Property = (): ReactElement => {
|
||||
return(
|
||||
<Grid
|
||||
container
|
||||
component="main"
|
||||
columns={12}
|
||||
spacing={3.75}
|
||||
flexGrow={1}
|
||||
pt={4.375}
|
||||
pr={1.875}
|
||||
pb={0}
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
pl: { xs: 3.75, lg: 0 },
|
||||
}}
|
||||
>
|
||||
<Grid xs={12} md={8}>
|
||||
<PropertyDetailsCard />
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<HomePriceEstimate />
|
||||
// Reusing the mockProperties from PropertyDetailPage for consistent data
|
||||
const mockProperties: PropertiesAPI[] = [
|
||||
{
|
||||
id: 101,
|
||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
||||
address: '123 Main St',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '500000',
|
||||
loan_amount: '300000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2020-05-01',
|
||||
created_at: '2020-04-20',
|
||||
last_updated: '2023-10-10',
|
||||
pictures: [
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Living',
|
||||
],
|
||||
description: 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
|
||||
sq_ft: 1800,
|
||||
features: ['Garage', 'Central AC', 'Hardwood Floors'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 34.0522, // Example coordinates for Los Angeles
|
||||
longitude: -118.2437,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
||||
address: '456 Oak Ave',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '750000',
|
||||
loan_amount: '500000',
|
||||
loan_term: 20,
|
||||
loan_start_date: '2022-01-10',
|
||||
created_at: '2021-12-01',
|
||||
last_updated: '2023-11-20',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
|
||||
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
|
||||
sq_ft: 2500,
|
||||
features: ['Pool', 'Fireplace', 'Large Yard'],
|
||||
num_bedrooms: 4,
|
||||
num_bathrooms: 3,
|
||||
latitude: 34.075, // Another example coordinate
|
||||
longitude: -118.30,
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
owner: { user: { id: 99, email: 'another.owner@example.com', first_name: 'Jane', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '987-654-3210' },
|
||||
address: '789 Pine Lane',
|
||||
city: 'Otherville',
|
||||
state: 'NY',
|
||||
zip_code: '10001',
|
||||
market_value: '1200000',
|
||||
loan_amount: '800000',
|
||||
loan_term: 15,
|
||||
loan_start_date: '2021-03-20',
|
||||
created_at: '2021-02-15',
|
||||
last_updated: '2024-01-05',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
|
||||
description: 'Luxury apartment in the heart of the city with stunning views.',
|
||||
sq_ft: 1200,
|
||||
features: ['City View', 'Gym Access', 'Doorman'],
|
||||
num_bedrooms: 2,
|
||||
num_bathrooms: 2,
|
||||
latitude: 40.7128, // NYC
|
||||
longitude: -74.0060,
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
owner: { user: { id: 100, email: 'test.user@example.com', first_name: 'Bob', last_name: 'Brown', user_type: 'property_owner', is_active: true, date_joined: '2024-01-01', tos_signed: true, profile_created: true, tier: 'premium' }, phone_number: '555-987-6543' },
|
||||
address: '101 Elm Street',
|
||||
city: 'Sampleton',
|
||||
state: 'TX',
|
||||
zip_code: '75001',
|
||||
market_value: '350000',
|
||||
loan_amount: '250000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2023-07-01',
|
||||
created_at: '2023-06-20',
|
||||
last_updated: '2024-06-15',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
|
||||
description: 'Cozy starter home with a large yard, ideal for families.',
|
||||
sq_ft: 1500,
|
||||
features: ['Large Yard', 'New Roof'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 32.7767, // Dallas
|
||||
longitude: -96.7970,
|
||||
}
|
||||
];
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<PhotoGalleryCard />
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<MarketStatistics />
|
||||
const Property: React.FC = () => {
|
||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<PropertyValueGraphCard />
|
||||
const filterProperties = (filters: any) => {
|
||||
const filtered = mockProperties.filter(property => {
|
||||
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
|
||||
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
|
||||
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
|
||||
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<LoanDetailsCard />
|
||||
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
||||
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
||||
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
||||
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
||||
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
||||
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
||||
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<PropertyListingCard />
|
||||
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
|
||||
sqFtMatch && bedroomsMatch && bathroomsMatch;
|
||||
});
|
||||
setSearchResults(filtered);
|
||||
setInitialLoad(false);
|
||||
};
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
const handleSearch = (filters: any) => {
|
||||
filterProperties(filters);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchResults([]); // Clear results on clear
|
||||
setInitialLoad(true); // Reset to initial state
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Property Search
|
||||
</Typography>
|
||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||
|
||||
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
||||
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{searchResults.length === 0 && !initialLoad ? (
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||
</Grid>
|
||||
) : (
|
||||
searchResults.map(property => (
|
||||
<Grid item xs={12} sm={6} md={4} key={property.id}>
|
||||
<PropertyListItem
|
||||
property={property}
|
||||
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Property;
|
||||
|
||||
// import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
// import { drawerWidth } from 'layouts/main-layout';
|
||||
// import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
|
||||
// import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
|
||||
// import PhotoGalleryCard from 'components/sections/dashboard/Home/Property/PhotoGalleryCard';
|
||||
// import MarketStatistics from 'components/sections/dashboard/Home/Property/MarketStatistics';
|
||||
// import PropertyListingCard from 'components/sections/dashboard/Home/Property/PropertyListingCard';
|
||||
// import LoanDetailsCard from 'components/sections/dashboard/Home/Property/LoanDetailsCard';
|
||||
// import PropertyValueGraphCard from 'components/sections/dashboard/Home/Property/PropertyValueGraphCard';
|
||||
// import {axiosInstance} from '../../axiosApi'
|
||||
// import { PropertiesAPI } from 'types';
|
||||
// import { AxiosResponse } from 'axios';
|
||||
// import LoadingSkeleton from 'components/base/LoadingSkeleton';
|
||||
// import { AccountContext } from 'contexts/AccountContext';
|
||||
// import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack } from '@mui/material';
|
||||
// import HouseIcon from '@mui/icons-material/House';
|
||||
// import { formatTimestamp } from 'utils';
|
||||
|
||||
|
||||
// const Property = (): ReactElement => {
|
||||
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
|
||||
// const {account, accountLoading} = useContext(AccountContext);
|
||||
// if(accountLoading){
|
||||
// return <LoadingSkeleton />
|
||||
// }
|
||||
// useEffect(() => {
|
||||
// const fetchProperties = async() => {
|
||||
// try{
|
||||
// const {data, }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/')
|
||||
// if(data.length > 0){
|
||||
// setProperties(data);
|
||||
// setSelectedPropertyId(data[0].id)
|
||||
|
||||
// }
|
||||
|
||||
|
||||
// }catch(error){
|
||||
// console.log(error)
|
||||
// }
|
||||
// }
|
||||
// fetchProperties();
|
||||
// }, [])
|
||||
|
||||
// const selectedProperty = properties.find(
|
||||
// (property) => property.id === selectedPropertyId
|
||||
// );
|
||||
|
||||
// return(
|
||||
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||
// <Grid container sx={{ minHeight: '100%' }}>
|
||||
// {/* Left Panel: Conversation List */}
|
||||
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||
// <Stack direction="row" sx={{width:'100%'}}>
|
||||
|
||||
|
||||
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
// Properties
|
||||
// </Typography>
|
||||
// </Stack>
|
||||
// </Box>
|
||||
// <List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
// {properties.length === 0 ? (
|
||||
// <Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
// <HouseIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
// <Typography>No properties yet. Go to profile to add one.</Typography>
|
||||
// </Box>
|
||||
// ) : (
|
||||
|
||||
// properties
|
||||
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
// .map((conv) => (
|
||||
// <ListItem
|
||||
// key={conv.id}
|
||||
// button
|
||||
// selected={selectedPropertyId === conv.id}
|
||||
// onClick={() => setSelectedPropertyId(conv.id)}
|
||||
// sx={{ py: 1.5, px: 2 }}
|
||||
// >
|
||||
// <ListItemText
|
||||
// primary={
|
||||
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
// {conv.address}
|
||||
// </Typography>
|
||||
// }
|
||||
// secondary={
|
||||
// <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
// <Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
|
||||
// {conv.market_value}
|
||||
// </Typography>
|
||||
// <Typography variant="caption" color="text.disabled">
|
||||
// {formatTimestamp(conv.last_updated)}
|
||||
// </Typography>
|
||||
// </Box>
|
||||
// }
|
||||
// />
|
||||
// </ListItem>
|
||||
// ))
|
||||
// )}
|
||||
// </List>
|
||||
// </Grid>
|
||||
|
||||
// {/* Right Panel: Conversation Detail */}
|
||||
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
// {selectedProperty ? (
|
||||
// <>
|
||||
// {/* Conversation Header */}
|
||||
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
// <Avatar sx={{ bgcolor: 'purple.200'}}>
|
||||
// {/* {selectedProperty.withName.split(' ').map(n => n[0]).join('')} */}
|
||||
// </Avatar>
|
||||
// <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
// {selectedProperty.address}
|
||||
// </Typography>
|
||||
// </Box>
|
||||
|
||||
// {/* Messages Area */}
|
||||
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
// <Grid
|
||||
// container
|
||||
// component="main"
|
||||
// columns={12}
|
||||
// spacing={3.75}
|
||||
// flexGrow={1}
|
||||
// pt={4.375}
|
||||
// pr={1.875}
|
||||
// pb={0}
|
||||
// sx={{
|
||||
// width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
// pl: { xs: 3.75, lg: 0 },
|
||||
// }}
|
||||
// >
|
||||
// <Grid xs={12} md={8}>
|
||||
|
||||
// <PropertyDetailsCard />
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={4}>
|
||||
// <HomePriceEstimate />
|
||||
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={8}>
|
||||
// <PhotoGalleryCard />
|
||||
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={4}>
|
||||
// <MarketStatistics />
|
||||
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={8}>
|
||||
// <PropertyValueGraphCard />
|
||||
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={4}>
|
||||
// <LoanDetailsCard />
|
||||
|
||||
// </Grid>
|
||||
// <Grid xs={12} md={4}>
|
||||
// <PropertyListingCard />
|
||||
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
|
||||
// </Box>
|
||||
|
||||
|
||||
// </>
|
||||
// ) : (
|
||||
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||
// <HouseIcon sx={{ fontSize: 80, mb: 2 }} />
|
||||
// <Typography variant="h6">Select a property to manage</Typography>
|
||||
// <Typography variant="body2">Click on a property from the left panel to get started.</Typography>
|
||||
// </Box>
|
||||
// )}
|
||||
// </Grid>
|
||||
// </Grid>
|
||||
// </Paper>
|
||||
// </Container>
|
||||
// )
|
||||
|
||||
// // return(
|
||||
// // <Grid
|
||||
// // container
|
||||
// // component="main"
|
||||
// // columns={12}
|
||||
// // spacing={3.75}
|
||||
// // flexGrow={1}
|
||||
// // pt={4.375}
|
||||
// // pr={1.875}
|
||||
// // pb={0}
|
||||
// // sx={{
|
||||
// // width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
// // pl: { xs: 3.75, lg: 0 },
|
||||
// // }}
|
||||
// // >
|
||||
// // <Grid xs={12} md={8}>
|
||||
|
||||
// // <PropertyDetailsCard />
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={4}>
|
||||
// // <HomePriceEstimate />
|
||||
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={8}>
|
||||
// // <PhotoGalleryCard />
|
||||
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={4}>
|
||||
// // <MarketStatistics />
|
||||
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={8}>
|
||||
// // <PropertyValueGraphCard />
|
||||
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={4}>
|
||||
// // <LoanDetailsCard />
|
||||
|
||||
// // </Grid>
|
||||
// // <Grid xs={12} md={4}>
|
||||
// // <PropertyListingCard />
|
||||
|
||||
// // </Grid>
|
||||
// // </Grid>
|
||||
// // )
|
||||
// }
|
||||
|
||||
// export default Property;
|
||||
200
ditch-the-agent/src/pages/Property/PropertyDetailPage.tsx
Normal file
200
ditch-the-agent/src/pages/Property/PropertyDetailPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material';
|
||||
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types';
|
||||
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
||||
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
||||
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
||||
import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseCard';
|
||||
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
||||
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
||||
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
|
||||
const PropertyDetailPage: React.FC = () => {
|
||||
// In a real app, you'd get propertyId from URL params or a global state
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
const { propertyId } = useParams<{ propertyId: string }>();
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isSearch = searchParams.get('search') === '1';
|
||||
|
||||
const [property, setProperty] = useState<PropertiesAPI | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
if (accountLoading) {
|
||||
return <>Page is loading</>;
|
||||
} else if (!accountLoading && !account) {
|
||||
return <>There was an error</>;
|
||||
} else {
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const getProperty = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = isSearch
|
||||
? `/properties/${propertyId}/?search=1`
|
||||
: `/properties/${propertyId}/`;
|
||||
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||
if (isSearch) {
|
||||
// kick the view count
|
||||
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
|
||||
}
|
||||
if (data !== undefined) {
|
||||
setProperty(data);
|
||||
}
|
||||
} catch {
|
||||
setError('Property not found.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
getProperty();
|
||||
}, [propertyId]);
|
||||
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
// In a real app, this would be an API call to update the property
|
||||
console.log('Saving property:', updatedProperty);
|
||||
setProperty(updatedProperty);
|
||||
setMessage({ type: 'success', text: 'Property details updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const onStatusChange = () => {
|
||||
if (property) {
|
||||
setProperty((property) => ({ ...property, status: 'active' }));
|
||||
}
|
||||
};
|
||||
|
||||
const onSavedPropertySave = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.post(`/saved-properties/`, {
|
||||
property: property.id,
|
||||
user: account.id,
|
||||
});
|
||||
console.log(response);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
};
|
||||
|
||||
const handleOfferSubmit = (offerAmount: number) => {
|
||||
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`);
|
||||
// Here you would send the offer to your backend API
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading property details...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="info">No property data available.</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if the current user is the owner of this property
|
||||
const isOwnerOfProperty = account.id === property.owner.user.id;
|
||||
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||
let listed_price: string;
|
||||
if (property.listed_price === undefined || property.listed_price === null) {
|
||||
listed_price = 'No Price';
|
||||
} else {
|
||||
listed_price = parseFloat(property.listed_price).toString();
|
||||
}
|
||||
|
||||
console.log(property);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Property Details */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
isPublicPage={true}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={isOwnerOfProperty}
|
||||
onDelete={handleDeleteProperty}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Status, Cost, Offers */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<PropertyStatusCard
|
||||
property={property}
|
||||
isOwner={isOwnerOfProperty}
|
||||
onStatusChange={onStatusChange}
|
||||
onSavedPropertySave={onSavedPropertySave}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<OfferSubmissionCard
|
||||
onOfferSubmit={handleOfferSubmit}
|
||||
listingStatus={property.property_status}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<OpenHouseCard openHouses={property.open_houses} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Information */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<WalkScoreCard walkScore={property.walk_score} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{property.schools && <SchoolCard schools={property.schools} />}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default PropertyDetailPage;
|
||||
170
ditch-the-agent/src/pages/Property/PropertySearchPage.tsx
Normal file
170
ditch-the-agent/src/pages/Property/PropertySearchPage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Container, Typography, Box, Grid, Alert } from '@mui/material';
|
||||
import { PropertiesAPI } from 'types';
|
||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import MapSerachComponent from 'components/base/MapSearchComponent';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import { useMapsLibrary } from '@vis.gl/react-google-maps';
|
||||
|
||||
const PropertySearchPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null);
|
||||
const [mapState, setMapState] = useState({
|
||||
center: { lat: 39.8283, lng: -98.5795 }, // Center of the US
|
||||
zoom: 4,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProperties = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> =
|
||||
await axiosInstance.get(`/properties/?search=1`);
|
||||
console.log(data);
|
||||
data.map((item) => {
|
||||
console.log(item);
|
||||
});
|
||||
if (data !== undefined) {
|
||||
setSearchResults(data);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setInitialLoad(true);
|
||||
}
|
||||
};
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
const filterProperties = async (filters: any) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
for (const key in filters) {
|
||||
if (filters.hasOwnProperty(key)) {
|
||||
const value = filters[key];
|
||||
|
||||
// Exclude attributes that don't have values (null, undefined, empty string)
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
searchParams.append(key, String(value)); // Ensure value is a string
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
console.log(queryString);
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(
|
||||
`/properties/?search=1&${queryString}`,
|
||||
);
|
||||
console.log(data);
|
||||
setSearchResults(data);
|
||||
} catch {
|
||||
} finally {
|
||||
setInitialLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (filters: any) => {
|
||||
console.log(filters);
|
||||
await filterProperties(filters);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchResults([]); // Clear results on clear
|
||||
setInitialLoad(true); // Reset to initial state
|
||||
};
|
||||
|
||||
const handleMapBoundsChange = ({ center, zoom, bounds }: any) => {
|
||||
setMapState({ center, zoom });
|
||||
// Optional: you could filter search results based on the map bounds
|
||||
};
|
||||
|
||||
const handleBoxDrawn = (bounds: any) => {
|
||||
const filtered = mockProperties.filter((p) => {
|
||||
if (!p.latitude || !p.longitude) return false;
|
||||
return (
|
||||
p.latitude <= bounds.ne.lat &&
|
||||
p.latitude >= bounds.sw.lat &&
|
||||
p.longitude <= bounds.ne.lng &&
|
||||
p.longitude >= bounds.sw.lng
|
||||
);
|
||||
});
|
||||
setSearchResults(filtered);
|
||||
setInitialLoad(false);
|
||||
// Optional: Adjust map zoom to fit the new search results
|
||||
// You'd need to calculate the new center and zoom based on the filtered results.
|
||||
};
|
||||
|
||||
const handleMarkerClick = (propertyId: number) => {
|
||||
navigate(`/properties/${propertyId}`);
|
||||
};
|
||||
|
||||
const handleMarkerHover = (property: PropertiesAPI) => {
|
||||
setSelectedPropertyId(property.id);
|
||||
};
|
||||
|
||||
const handleMarkerUnhover = () => {
|
||||
setSelectedPropertyId(null);
|
||||
};
|
||||
|
||||
// Handler for a map marker click, this will navigate to the details page
|
||||
const handleMapMarkerClick = (propertyId: number) => {
|
||||
navigate(`/properties/${propertyId}/&search=1`);
|
||||
};
|
||||
|
||||
console.log(searchResults);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Property Search & Map
|
||||
</Typography>
|
||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Property List Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
||||
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
||||
</Typography>
|
||||
{searchResults.length === 0 ? (
|
||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||
) : (
|
||||
searchResults.map((property) => (
|
||||
<PropertyListItem
|
||||
key={property.id}
|
||||
property={property}
|
||||
onHover={handleMarkerHover}
|
||||
onUnhover={handleMarkerUnhover}
|
||||
// The click logic will now be handled by the marker
|
||||
// We just highlight the selected marker
|
||||
isSelected={selectedPropertyId === property.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Map Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<MapSerachComponent
|
||||
center={mapState.center}
|
||||
zoom={mapState.zoom}
|
||||
properties={searchResults}
|
||||
selectedPropertyId={selectedPropertyId}
|
||||
onBoundsChanged={handleMapBoundsChange}
|
||||
onBoxDrawn={handleBoxDrawn}
|
||||
onMarkerClick={handleMapMarkerClick} // Pass the navigation handler
|
||||
onMarkerHover={handleMarkerHover}
|
||||
onMarkerUnhover={handleMarkerUnhover}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertySearchPage;
|
||||
238
ditch-the-agent/src/pages/Tools/AmoritizationTable.tsx
Normal file
238
ditch-the-agent/src/pages/Tools/AmoritizationTable.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const AmoritizationTable = (): ReactElement => {
|
||||
const [principal, setPrincipal] = useState<number | ''>('');
|
||||
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
|
||||
const [loanTermYears, setLoanTermYears] = useState<number | ''>('');
|
||||
const [amortizationSchedule, setAmortizationSchedule] = useState<AmortizationRow[]>([]);
|
||||
const [monthlyPayment, setMonthlyPayment] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to calculate the amortization schedule
|
||||
const calculateAmortization = () => {
|
||||
// Reset error message
|
||||
setError(null);
|
||||
|
||||
// Input validation
|
||||
if (principal === '' || annualInterestRate === '' || loanTermYears === '') {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
if (principal <= 0) {
|
||||
setError('Principal must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
if (annualInterestRate < 0) {
|
||||
setError('Interest rate cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (loanTermYears <= 0) {
|
||||
setError('Loan term must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
|
||||
const p = Number(principal);
|
||||
const annualRate = Number(annualInterestRate);
|
||||
const years = Number(loanTermYears);
|
||||
|
||||
const monthlyRate = annualRate / 100 / 12; // Convert annual percentage rate to monthly decimal rate
|
||||
const numberOfPayments = years * 12; // Total number of monthly payments
|
||||
|
||||
let calculatedMonthlyPayment = 0;
|
||||
|
||||
// Calculate monthly payment using the loan amortization formula
|
||||
if (monthlyRate === 0) {
|
||||
// If interest rate is 0, monthly payment is just principal / number of payments
|
||||
calculatedMonthlyPayment = p / numberOfPayments;
|
||||
} else {
|
||||
calculatedMonthlyPayment =
|
||||
(p * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
|
||||
}
|
||||
|
||||
setMonthlyPayment(calculatedMonthlyPayment);
|
||||
|
||||
const schedule: AmortizationRow[] = [];
|
||||
let currentBalance = p;
|
||||
|
||||
// Generate amortization schedule month by month
|
||||
for (let i = 1; i <= numberOfPayments; i++) {
|
||||
const interestPaid = currentBalance * monthlyRate;
|
||||
let principalPaid = calculatedMonthlyPayment - interestPaid;
|
||||
|
||||
// Adjust last payment to account for potential rounding errors
|
||||
if (i === numberOfPayments) {
|
||||
principalPaid = currentBalance; // Pay off the remaining balance
|
||||
calculatedMonthlyPayment = interestPaid + principalPaid; // Adjust last payment amount
|
||||
}
|
||||
|
||||
const endingBalance = currentBalance - principalPaid;
|
||||
|
||||
schedule.push({
|
||||
month: i,
|
||||
startingBalance: currentBalance,
|
||||
monthlyPayment: calculatedMonthlyPayment,
|
||||
interestPaid: interestPaid,
|
||||
principalPaid: principalPaid,
|
||||
endingBalance: endingBalance < 0 ? 0 : endingBalance, // Ensure ending balance doesn't go negative
|
||||
});
|
||||
|
||||
currentBalance = endingBalance;
|
||||
}
|
||||
setAmortizationSchedule(schedule);
|
||||
};
|
||||
|
||||
// Memoize the total interest paid for display
|
||||
const totalInterestPaid = useMemo(() => {
|
||||
return amortizationSchedule.reduce((sum, row) => sum + row.interestPaid, 0);
|
||||
}, [amortizationSchedule]);
|
||||
|
||||
// Memoize the total principal paid (should be equal to initial principal)
|
||||
const totalPrincipalPaid = useMemo(() => {
|
||||
return amortizationSchedule.reduce((sum, row) => sum + row.principalPaid, 0);
|
||||
}, [amortizationSchedule]);
|
||||
|
||||
// Memoize the total cost of the loan
|
||||
const totalCostOfLoan = useMemo(() => {
|
||||
return monthlyPayment * (loanTermYears === '' ? 0 : Number(loanTermYears) * 12);
|
||||
}, [monthlyPayment, loanTermYears]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ my: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Loan Amortization Calculator
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Calculate your loan payments and see the amortization schedule.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: 3,
|
||||
mb: 3,
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
label="Principal Loan Amount ($)"
|
||||
type="number"
|
||||
value={principal}
|
||||
onChange={(e) => setPrincipal(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Annual Interest Rate (%)"
|
||||
type="number"
|
||||
value={annualInterestRate}
|
||||
onChange={(e) =>
|
||||
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Loan Term (Years)"
|
||||
type="number"
|
||||
value={loanTermYears}
|
||||
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={calculateAmortization}
|
||||
fullWidth
|
||||
size="large"
|
||||
>
|
||||
Calculate Amortization
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{amortizationSchedule.length > 0 && (
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
|
||||
Summary
|
||||
</Typography>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6">Monthly Payment: ${monthlyPayment.toFixed(2)}</Typography>
|
||||
<Typography variant="body1">
|
||||
Total Principal Paid: ${totalPrincipalPaid.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Total Interest Paid: ${totalInterestPaid.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Total Cost of Loan: ${totalCostOfLoan.toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
|
||||
Amortization Schedule
|
||||
</Typography>
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 600, overflow: 'auto' }}>
|
||||
<Table stickyHeader aria-label="amortization table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Month</TableCell>
|
||||
<TableCell align="right">Starting Balance</TableCell>
|
||||
<TableCell align="right">Monthly Payment</TableCell>
|
||||
<TableCell align="right">Interest Paid</TableCell>
|
||||
<TableCell align="right">Principal Paid</TableCell>
|
||||
<TableCell align="right">Ending Balance</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{amortizationSchedule.map((row) => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell component="th" scope="row">
|
||||
{row.month}
|
||||
</TableCell>
|
||||
<TableCell align="right">${row.startingBalance.toFixed(2)}</TableCell>
|
||||
<TableCell align="right">${row.monthlyPayment.toFixed(2)}</TableCell>
|
||||
<TableCell align="right">${row.interestPaid.toFixed(2)}</TableCell>
|
||||
<TableCell align="right">${row.principalPaid.toFixed(2)}</TableCell>
|
||||
<TableCell align="right">${row.endingBalance.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AmoritizationTable;
|
||||
392
ditch-the-agent/src/pages/Tools/HomeAffordability.tsx
Normal file
392
ditch-the-agent/src/pages/Tools/HomeAffordability.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Slider,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const HomeAffordability = (): ReactElement => {
|
||||
// State variables for affordability inputs
|
||||
const [annualIncome, setAnnualIncome] = useState<number | ''>('');
|
||||
const [downPayment, setDownPayment] = useState<number | ''>('');
|
||||
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
|
||||
const [loanTermYears, setLoanTermYears] = useState<number | ''>(30); // Default to 30 years
|
||||
const [pmiRate, setPmiRate] = useState<number | ''>(0.5); // Default PMI rate as percentage
|
||||
const [propertyTaxRate, setPropertyTaxRate] = useState<number | ''>(1.2); // Default annual property tax rate as percentage
|
||||
const [homeInsuranceAnnual, setHomeInsuranceAnnual] = useState<number | ''>(1200); // Default annual home insurance
|
||||
const [otherMonthlyDebts, setOtherMonthlyDebts] = useState<number | ''>(0); // e.g., car payments, student loans
|
||||
const [maxDebtToIncomeRatio, setMaxDebtToIncomeRatio] = useState<number>(36); // Default DTI ratio as percentage
|
||||
|
||||
// State variables for calculated results
|
||||
const [affordableHomePrice, setAffordableHomePrice] = useState<number>(0);
|
||||
const [estimatedMonthlyPayment, setEstimatedMonthlyPayment] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to calculate home affordability
|
||||
const calculateAffordability = () => {
|
||||
// Reset error message and previous results
|
||||
setError(null);
|
||||
setAffordableHomePrice(0);
|
||||
setEstimatedMonthlyPayment(0);
|
||||
|
||||
// Input validation
|
||||
if (
|
||||
annualIncome === '' ||
|
||||
annualInterestRate === '' ||
|
||||
loanTermYears === '' ||
|
||||
downPayment === ''
|
||||
) {
|
||||
setError('Please fill in Annual Income, Down Payment, Annual Interest Rate, and Loan Term.');
|
||||
return;
|
||||
}
|
||||
if (Number(annualIncome) <= 0) {
|
||||
setError('Annual Income must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
if (Number(downPayment) < 0) {
|
||||
setError('Down Payment cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (Number(annualInterestRate) < 0) {
|
||||
setError('Annual Interest Rate cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (Number(loanTermYears) <= 0) {
|
||||
setError('Loan Term must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
if (Number(pmiRate) < 0 || Number(pmiRate) > 100) {
|
||||
setError('PMI Rate must be between 0 and 100.');
|
||||
return;
|
||||
}
|
||||
if (Number(propertyTaxRate) < 0 || Number(propertyTaxRate) > 100) {
|
||||
setError('Property Tax Rate must be between 0 and 100.');
|
||||
return;
|
||||
}
|
||||
if (Number(homeInsuranceAnnual) < 0) {
|
||||
setError('Annual Home Insurance cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (Number(otherMonthlyDebts) < 0) {
|
||||
setError('Other Monthly Debts cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (Number(maxDebtToIncomeRatio) <= 0 || Number(maxDebtToIncomeRatio) > 100) {
|
||||
setError('Max Debt-to-Income Ratio must be between 1 and 100.');
|
||||
return;
|
||||
}
|
||||
|
||||
const income = Number(annualIncome);
|
||||
const dp = Number(downPayment);
|
||||
const annualRate = Number(annualInterestRate);
|
||||
const years = Number(loanTermYears);
|
||||
const pmiPercent = Number(pmiRate) / 100;
|
||||
const taxPercent = Number(propertyTaxRate) / 100;
|
||||
const insuranceAnnual = Number(homeInsuranceAnnual);
|
||||
const otherDebts = Number(otherMonthlyDebts);
|
||||
const maxDTI = Number(maxDebtToIncomeRatio) / 100;
|
||||
|
||||
const monthlyIncome = income / 12;
|
||||
const maxMonthlyHousingPayment = monthlyIncome * maxDTI - otherDebts;
|
||||
|
||||
if (maxMonthlyHousingPayment <= 0) {
|
||||
setError(
|
||||
'Your calculated maximum affordable monthly housing payment is zero or negative. Adjust inputs.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Estimate affordable home price iteratively or using a more complex formula
|
||||
// For simplicity, we'll use an iterative approach or a target monthly payment
|
||||
// Let's assume the maximum monthly housing payment is the target for PITI.
|
||||
|
||||
// PITI = Principal & Interest + Property Tax + Home Insurance + PMI
|
||||
// We need to find the Loan Amount (L) such that PITI is <= maxMonthlyHousingPayment
|
||||
// L = Home Price - Down Payment
|
||||
// P&I = L * (monthlyRate / (1 - (1 + monthlyRate)^-numPayments))
|
||||
// Property Tax (monthly) = Home Price * (annualTaxRate / 12)
|
||||
// Home Insurance (monthly) = Annual Insurance / 12
|
||||
// PMI (monthly) = L * (pmiRate / 12) -- typically based on loan amount, not home price
|
||||
|
||||
// This becomes a bit of an algebraic challenge to solve directly for Home Price.
|
||||
// A common approach is to solve for the maximum loan amount first, then add the down payment.
|
||||
|
||||
const monthlyRate = annualRate / 100 / 12;
|
||||
const numberOfPayments = years * 12;
|
||||
|
||||
// We need to estimate the maximum loan amount (L) that can be supported by maxMonthlyHousingPayment
|
||||
// Let's re-frame:
|
||||
// Max monthly payment for P&I + PMI = maxMonthlyHousingPayment - (Monthly Tax + Monthly Insurance)
|
||||
const monthlyInsurance = insuranceAnnual / 12;
|
||||
// We can't calculate monthly tax directly from home price yet, so we'll need to iterate or estimate.
|
||||
|
||||
// Let's assume a starting guess for the affordable home price and refine.
|
||||
// Or, work backwards from the maximum monthly payment.
|
||||
|
||||
// Option 1: Work backwards from max monthly housing payment to find max loan amount
|
||||
// M = P * [ i(1 + i)^n ] / [ (1 + i)^n – 1]
|
||||
// P = M / [ i(1 + i)^n ] / [ (1 + i)^n – 1]
|
||||
// Here, M is the portion of the maxMonthlyHousingPayment that can go towards P&I.
|
||||
// This is tricky because Tax and PMI depend on the home price/loan amount.
|
||||
|
||||
// Let's use an iterative approach to find the affordable home price.
|
||||
let estimatedHomePrice = 0;
|
||||
let high = 5000000; // Upper bound for home price search
|
||||
let low = 10000; // Lower bound
|
||||
let iterations = 100; // Number of iterations for binary search
|
||||
|
||||
while (iterations > 0 && high - low > 0.01) {
|
||||
// Iterate until precision is met
|
||||
const midPrice = (low + high) / 2;
|
||||
const loan = midPrice - dp;
|
||||
|
||||
if (loan <= 0) {
|
||||
// If loan is not positive, this price is too low
|
||||
low = midPrice;
|
||||
iterations--;
|
||||
continue;
|
||||
}
|
||||
|
||||
let estimatedPI = 0;
|
||||
if (monthlyRate === 0) {
|
||||
estimatedPI = loan / numberOfPayments;
|
||||
} else {
|
||||
estimatedPI = (loan * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
|
||||
}
|
||||
|
||||
const estimatedMonthlyTax = midPrice * (taxPercent / 12);
|
||||
const estimatedMonthlyPMI = loan * (pmiPercent / 12); // PMI usually on loan amount
|
||||
const currentEstimatedTotalMonthlyPayment =
|
||||
estimatedPI + estimatedMonthlyTax + monthlyInsurance + estimatedMonthlyPMI;
|
||||
|
||||
if (currentEstimatedTotalMonthlyPayment <= maxMonthlyHousingPayment) {
|
||||
estimatedHomePrice = midPrice;
|
||||
low = midPrice; // Try for a higher price
|
||||
} else {
|
||||
high = midPrice; // Price is too high, reduce it
|
||||
}
|
||||
iterations--;
|
||||
}
|
||||
|
||||
setAffordableHomePrice(estimatedHomePrice);
|
||||
|
||||
// Calculate the estimated monthly payment for the affordable price
|
||||
const finalLoanAmount = estimatedHomePrice - dp;
|
||||
let finalPI = 0;
|
||||
if (monthlyRate === 0) {
|
||||
finalPI = finalLoanAmount / numberOfPayments;
|
||||
} else {
|
||||
finalPI =
|
||||
(finalLoanAmount * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
|
||||
}
|
||||
const finalMonthlyTax = estimatedHomePrice * (taxPercent / 12);
|
||||
const finalMonthlyPMI = finalLoanAmount * (pmiPercent / 12);
|
||||
const finalEstimatedMonthlyPayment =
|
||||
finalPI + finalMonthlyTax + monthlyInsurance + finalMonthlyPMI;
|
||||
setEstimatedMonthlyPayment(finalEstimatedMonthlyPayment);
|
||||
|
||||
if (estimatedHomePrice === 0 && maxMonthlyHousingPayment > 0) {
|
||||
setError(
|
||||
'Could not find an affordable home price with the given inputs. Try adjusting values.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ my: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Home Affordability Calculator
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Estimate how much home you can afford based on your income and debts.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
|
||||
gap: 3,
|
||||
mb: 3,
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Income and Down Payment */}
|
||||
<TextField
|
||||
label="Annual Income ($)"
|
||||
type="number"
|
||||
value={annualIncome}
|
||||
onChange={(e) => setAnnualIncome(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Down Payment ($)"
|
||||
type="number"
|
||||
value={downPayment}
|
||||
onChange={(e) => setDownPayment(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
|
||||
{/* Loan Details */}
|
||||
<TextField
|
||||
label="Annual Interest Rate (%)"
|
||||
type="number"
|
||||
value={annualInterestRate}
|
||||
onChange={(e) =>
|
||||
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Loan Term (Years)"
|
||||
type="number"
|
||||
value={loanTermYears}
|
||||
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
|
||||
{/* Other Costs */}
|
||||
<TextField
|
||||
label="PMI Rate (Annual % of Loan Amount)"
|
||||
type="number"
|
||||
value={pmiRate}
|
||||
onChange={(e) => setPmiRate(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Property Tax Rate (Annual % of Home Price)"
|
||||
type="number"
|
||||
value={propertyTaxRate}
|
||||
onChange={(e) =>
|
||||
setPropertyTaxRate(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Annual Home Insurance ($)"
|
||||
type="number"
|
||||
value={homeInsuranceAnnual}
|
||||
onChange={(e) =>
|
||||
setHomeInsuranceAnnual(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Other Monthly Debts ($)"
|
||||
type="number"
|
||||
value={otherMonthlyDebts}
|
||||
onChange={(e) =>
|
||||
setOtherMonthlyDebts(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
|
||||
{/* Debt-to-Income Ratio */}
|
||||
<Box sx={{ gridColumn: { xs: 'span 1', md: 'span 2' }, mt: 2 }}>
|
||||
<Typography gutterBottom>Max Debt-to-Income Ratio (%)</Typography>
|
||||
<Slider
|
||||
value={typeof maxDebtToIncomeRatio === 'number' ? maxDebtToIncomeRatio : 0}
|
||||
onChange={(_, newValue) => setMaxDebtToIncomeRatio(newValue as number)}
|
||||
aria-labelledby="input-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={1}
|
||||
marks
|
||||
min={10}
|
||||
max={50}
|
||||
sx={{ width: '95%', margin: '0 auto' }}
|
||||
/>
|
||||
<TextField
|
||||
value={maxDebtToIncomeRatio}
|
||||
onChange={(e) => setMaxDebtToIncomeRatio(Number(e.target.value))}
|
||||
type="number"
|
||||
inputProps={{
|
||||
step: 1,
|
||||
min: 10,
|
||||
max: 50,
|
||||
'aria-labelledby': 'input-slider',
|
||||
}}
|
||||
sx={{ width: '100px', ml: 'auto' }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={calculateAffordability}
|
||||
fullWidth
|
||||
size="large"
|
||||
>
|
||||
Calculate Affordability
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{affordableHomePrice > 0 && (
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
|
||||
Affordability Summary
|
||||
</Typography>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
Estimated Affordable Home Price: ${affordableHomePrice.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 1 }}>
|
||||
Estimated Monthly Payment (PITI + PMI): ${estimatedMonthlyPayment.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
*This calculation is an estimate based on your inputs and a common debt-to-income
|
||||
ratio approach. Actual affordability may vary based on lender criteria, credit score,
|
||||
and other factors.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeAffordability;
|
||||
292
ditch-the-agent/src/pages/Tools/MortgageCalculator.tsx
Normal file
292
ditch-the-agent/src/pages/Tools/MortgageCalculator.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const MortgageCalculator = (): ReactElement => {
|
||||
// State variables for mortgage inputs
|
||||
const [loanAmount, setLoanAmount] = useState<number | ''>('');
|
||||
const [annualInterestRate, setAnnualInterestRate] = useState<number | ''>('');
|
||||
const [loanTermYears, setLoanTermYears] = useState<number | ''>('');
|
||||
const [pmi, setPmi] = useState<number | ''>(''); // Private Mortgage Insurance (monthly)
|
||||
const [hoaFees, setHoaFees] = useState<number | ''>(''); // Homeowners Association Fees (monthly)
|
||||
const [homeInsurance, setHomeInsurance] = useState<number | ''>(''); // Home Insurance (monthly)
|
||||
const [propertyTax, setPropertyTax] = useState<number | ''>(''); // Property Tax (annual)
|
||||
|
||||
// State variables for calculated results
|
||||
const [monthlyPrincipalInterest, setMonthlyPrincipalInterest] = useState<number>(0);
|
||||
const [totalMonthlyPayment, setTotalMonthlyPayment] = useState<number>(0);
|
||||
const [totalLoanCost, setTotalLoanCost] = useState<number>(0);
|
||||
const [totalPrincipalPaid, setTotalPrincipalPaid] = useState<number>(0);
|
||||
const [totalInterestPaid, setTotalInterestPaid] = useState<number>(0);
|
||||
const [totalPmiPaid, setTotalPmiPaid] = useState<number>(0);
|
||||
const [totalHoaFeesPaid, setTotalHoaFeesPaid] = useState<number>(0);
|
||||
const [totalHomeInsurancePaid, setTotalHomeInsurancePaid] = useState<number>(0);
|
||||
const [totalPropertyTaxPaid, setTotalPropertyTaxPaid] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to calculate the mortgage costs
|
||||
const calculateMortgage = () => {
|
||||
// Reset error message and previous results
|
||||
setError(null);
|
||||
setMonthlyPrincipalInterest(0);
|
||||
setTotalMonthlyPayment(0);
|
||||
setTotalLoanCost(0);
|
||||
setTotalPrincipalPaid(0);
|
||||
setTotalInterestPaid(0);
|
||||
setTotalPmiPaid(0);
|
||||
setTotalHoaFeesPaid(0);
|
||||
setTotalHomeInsurancePaid(0);
|
||||
setTotalPropertyTaxPaid(0);
|
||||
|
||||
// Input validation
|
||||
if (loanAmount === '' || annualInterestRate === '' || loanTermYears === '') {
|
||||
setError('Please fill in Loan Amount, Annual Interest Rate, and Loan Term.');
|
||||
return;
|
||||
}
|
||||
if (loanAmount <= 0) {
|
||||
setError('Loan Amount must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
if (annualInterestRate < 0) {
|
||||
setError('Annual Interest Rate cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (loanTermYears <= 0) {
|
||||
setError('Loan Term must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
|
||||
const p = Number(loanAmount);
|
||||
const annualRate = Number(annualInterestRate);
|
||||
const years = Number(loanTermYears);
|
||||
|
||||
const monthlyRate = annualRate / 100 / 12; // Convert annual percentage rate to monthly decimal rate
|
||||
const numberOfPayments = years * 12; // Total number of monthly payments
|
||||
|
||||
let calculatedMonthlyPI = 0; // Principal & Interest payment
|
||||
|
||||
// Calculate monthly Principal & Interest payment using the loan amortization formula
|
||||
if (monthlyRate === 0) {
|
||||
// If interest rate is 0, monthly P&I is just principal / number of payments
|
||||
calculatedMonthlyPI = p / numberOfPayments;
|
||||
} else {
|
||||
calculatedMonthlyPI = (p * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
|
||||
}
|
||||
|
||||
setMonthlyPrincipalInterest(calculatedMonthlyPI);
|
||||
|
||||
// Calculate total costs over the loan term
|
||||
const totalPrincipal = p;
|
||||
let totalInterest = 0;
|
||||
let currentBalance = p;
|
||||
|
||||
// Simulate interest accumulation for total interest calculation
|
||||
for (let i = 1; i <= numberOfPayments; i++) {
|
||||
const interestForMonth = currentBalance * monthlyRate;
|
||||
totalInterest += interestForMonth;
|
||||
const principalPaidForMonth = calculatedMonthlyPI - interestForMonth;
|
||||
currentBalance -= principalPaidForMonth;
|
||||
if (currentBalance < 0) currentBalance = 0; // Prevent negative balance due to rounding
|
||||
}
|
||||
|
||||
// Ensure total interest is non-negative and principal is accurate
|
||||
totalInterest = Math.max(0, totalInterest);
|
||||
// Recalculate total interest based on total payments if needed for precision
|
||||
// totalInterest = (calculatedMonthlyPI * numberOfPayments) - p;
|
||||
|
||||
const monthlyPmi = pmi === '' ? 0 : Number(pmi);
|
||||
const monthlyHoa = hoaFees === '' ? 0 : Number(hoaFees);
|
||||
const monthlyInsurance = homeInsurance === '' ? 0 : Number(homeInsurance);
|
||||
const monthlyPropertyTax = propertyTax === '' ? 0 : Number(propertyTax) / 12; // Convert annual to monthly
|
||||
|
||||
// Calculate total monthly payment including all fees
|
||||
const calculatedTotalMonthlyPayment =
|
||||
calculatedMonthlyPI + monthlyPmi + monthlyHoa + monthlyInsurance + monthlyPropertyTax;
|
||||
setTotalMonthlyPayment(calculatedTotalMonthlyPayment);
|
||||
|
||||
// Calculate total costs over the entire loan term
|
||||
const totalPaymentsCount = numberOfPayments; // Total months
|
||||
|
||||
const totalPmi = monthlyPmi * totalPaymentsCount;
|
||||
const totalHoa = monthlyHoa * totalPaymentsCount;
|
||||
const totalInsurance = monthlyInsurance * totalPaymentsCount;
|
||||
const totalPropertyTax = monthlyPropertyTax * totalPaymentsCount;
|
||||
|
||||
setTotalPrincipalPaid(totalPrincipal);
|
||||
setTotalInterestPaid(totalInterest);
|
||||
setTotalPmiPaid(totalPmi);
|
||||
setTotalHoaFeesPaid(totalHoa);
|
||||
setTotalHomeInsurancePaid(totalInsurance);
|
||||
setTotalPropertyTaxPaid(totalPropertyTax);
|
||||
|
||||
const calculatedTotalLoanCost =
|
||||
totalPrincipal + totalInterest + totalPmi + totalHoa + totalInsurance + totalPropertyTax;
|
||||
setTotalLoanCost(calculatedTotalLoanCost);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ my: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Mortgage Cost Calculator
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Estimate your total monthly mortgage payment and overall loan cost.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
|
||||
gap: 3,
|
||||
mb: 3,
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Loan Details */}
|
||||
<TextField
|
||||
label="Loan Amount ($)"
|
||||
type="number"
|
||||
value={loanAmount}
|
||||
onChange={(e) => setLoanAmount(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Annual Interest Rate (%)"
|
||||
type="number"
|
||||
value={annualInterestRate}
|
||||
onChange={(e) =>
|
||||
setAnnualInterestRate(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Loan Term (Years)"
|
||||
type="number"
|
||||
value={loanTermYears}
|
||||
onChange={(e) => setLoanTermYears(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
|
||||
{/* Additional Monthly Costs */}
|
||||
<TextField
|
||||
label="PMI (Monthly $)"
|
||||
type="number"
|
||||
value={pmi}
|
||||
onChange={(e) => setPmi(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="HOA Fees (Monthly $)"
|
||||
type="number"
|
||||
value={hoaFees}
|
||||
onChange={(e) => setHoaFees(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Home Insurance (Monthly $)"
|
||||
type="number"
|
||||
value={homeInsurance}
|
||||
onChange={(e) => setHomeInsurance(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Property Tax (Annual $)"
|
||||
type="number"
|
||||
value={propertyTax}
|
||||
onChange={(e) => setPropertyTax(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={calculateMortgage}
|
||||
fullWidth
|
||||
size="large"
|
||||
>
|
||||
Calculate Mortgage Cost
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{totalMonthlyPayment > 0 && (
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
|
||||
Mortgage Summary
|
||||
</Typography>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
Estimated Monthly Payment: ${totalMonthlyPayment.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, fontWeight: 'bold' }}>
|
||||
Total Cost Breakdown Over {loanTermYears} Years:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Total Principal: ${totalPrincipalPaid.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body2">Total Interest: ${totalInterestPaid.toFixed(2)}</Typography>
|
||||
{pmi !== '' && Number(pmi) > 0 && (
|
||||
<Typography variant="body2">Total PMI: ${totalPmiPaid.toFixed(2)}</Typography>
|
||||
)}
|
||||
{hoaFees !== '' && Number(hoaFees) > 0 && (
|
||||
<Typography variant="body2">
|
||||
Total HOA Fees: ${totalHoaFeesPaid.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
{homeInsurance !== '' && Number(homeInsurance) > 0 && (
|
||||
<Typography variant="body2">
|
||||
Total Home Insurance: ${totalHomeInsurancePaid.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
{propertyTax !== '' && Number(propertyTax) > 0 && (
|
||||
<Typography variant="body2">
|
||||
Total Property Tax: ${totalPropertyTaxPaid.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
Overall Loan Cost: ${totalLoanCost.toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MortgageCalculator;
|
||||
292
ditch-the-agent/src/pages/Tools/NetTermsSheet.tsx
Normal file
292
ditch-the-agent/src/pages/Tools/NetTermsSheet.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const NetTermsSheet = (): ReactElement => {
|
||||
// State variables for home sale inputs
|
||||
const [salePrice, setSalePrice] = useState<number | ''>('');
|
||||
const [mortgagePayoff, setMortgagePayoff] = useState<number | ''>('');
|
||||
const [agentCommissionRate, setAgentCommissionRate] = useState<number | ''>(6); // Default 6%
|
||||
const [titleInsuranceOwner, setTitleInsuranceOwner] = useState<number | ''>(0);
|
||||
const [escrowFees, setEscrowFees] = useState<number | ''>(0);
|
||||
const [recordingFees, setRecordingFees] = useState<number | ''>(0);
|
||||
const [transferTaxes, setTransferTaxes] = useState<number | ''>(0);
|
||||
const [attorneyFees, setAttorneyFees] = useState<number | ''>(0);
|
||||
const [proratedPropertyTaxes, setProratedPropertyTaxes] = useState<number | ''>(0);
|
||||
const [proratedHoaDues, setProratedHoaDues] = useState<number | ''>(0);
|
||||
const [sellerConcessions, setSellerConcessions] = useState<number | ''>(0);
|
||||
const [otherFees, setOtherFees] = useState<number | ''>(0);
|
||||
|
||||
// State variables for calculated results
|
||||
const [totalClosingCosts, setTotalClosingCosts] = useState<number>(0);
|
||||
const [netProceeds, setNetProceeds] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to calculate the closing costs and net proceeds
|
||||
const calculateNetTerms = () => {
|
||||
// Reset error message and previous results
|
||||
setError(null);
|
||||
setTotalClosingCosts(0);
|
||||
setNetProceeds(0);
|
||||
|
||||
// Input validation
|
||||
if (salePrice === '') {
|
||||
setError('Please enter the Sale Price.');
|
||||
return;
|
||||
}
|
||||
if (Number(salePrice) <= 0) {
|
||||
setError('Sale Price must be greater than 0.');
|
||||
return;
|
||||
}
|
||||
if (Number(mortgagePayoff) < 0) {
|
||||
setError('Mortgage Payoff cannot be negative.');
|
||||
return;
|
||||
}
|
||||
if (Number(agentCommissionRate) < 0 || Number(agentCommissionRate) > 100) {
|
||||
setError('Agent Commission Rate must be between 0 and 100.');
|
||||
return;
|
||||
}
|
||||
|
||||
const price = Number(salePrice);
|
||||
const payoff = mortgagePayoff === '' ? 0 : Number(mortgagePayoff);
|
||||
const commissionRate = Number(agentCommissionRate) / 100; // Convert to decimal
|
||||
|
||||
// Calculate agent commission
|
||||
const agentCommission = price * commissionRate;
|
||||
|
||||
// Sum up all other closing costs
|
||||
const otherFixedCosts =
|
||||
(titleInsuranceOwner === '' ? 0 : Number(titleInsuranceOwner)) +
|
||||
(escrowFees === '' ? 0 : Number(escrowFees)) +
|
||||
(recordingFees === '' ? 0 : Number(recordingFees)) +
|
||||
(transferTaxes === '' ? 0 : Number(transferTaxes)) +
|
||||
(attorneyFees === '' ? 0 : Number(attorneyFees)) +
|
||||
(proratedPropertyTaxes === '' ? 0 : Number(proratedPropertyTaxes)) +
|
||||
(proratedHoaDues === '' ? 0 : Number(proratedHoaDues)) +
|
||||
(sellerConcessions === '' ? 0 : Number(sellerConcessions)) +
|
||||
(otherFees === '' ? 0 : Number(otherFees));
|
||||
|
||||
// Total closing costs
|
||||
const calculatedTotalClosingCosts = agentCommission + otherFixedCosts;
|
||||
setTotalClosingCosts(calculatedTotalClosingCosts);
|
||||
|
||||
// Calculate net proceeds
|
||||
const calculatedNetProceeds = price - payoff - calculatedTotalClosingCosts;
|
||||
setNetProceeds(calculatedNetProceeds);
|
||||
|
||||
if (calculatedNetProceeds < 0) {
|
||||
setError('Your estimated net proceeds are negative. You might owe money at closing.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ my: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Home Sale Closing Costs Calculator
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Estimate your net proceeds from a home sale by calculating closing costs.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 4, mb: 4 }}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, // Two columns on medium screens and up
|
||||
gap: 3,
|
||||
mb: 3,
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* Primary Sale Details */}
|
||||
<TextField
|
||||
label="Home Sale Price ($)"
|
||||
type="number"
|
||||
value={salePrice}
|
||||
onChange={(e) => setSalePrice(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Mortgage Payoff Amount ($)"
|
||||
type="number"
|
||||
value={mortgagePayoff}
|
||||
onChange={(e) => setMortgagePayoff(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
|
||||
{/* Commission and Other Fees */}
|
||||
<TextField
|
||||
label="Agent Commission Rate (%)"
|
||||
type="number"
|
||||
value={agentCommissionRate}
|
||||
onChange={(e) =>
|
||||
setAgentCommissionRate(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Title Insurance (Owner's Policy) ($)"
|
||||
type="number"
|
||||
value={titleInsuranceOwner}
|
||||
onChange={(e) =>
|
||||
setTitleInsuranceOwner(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Escrow Fees ($)"
|
||||
type="number"
|
||||
value={escrowFees}
|
||||
onChange={(e) => setEscrowFees(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Recording Fees ($)"
|
||||
type="number"
|
||||
value={recordingFees}
|
||||
onChange={(e) => setRecordingFees(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Transfer Taxes / Deed Stamps ($)"
|
||||
type="number"
|
||||
value={transferTaxes}
|
||||
onChange={(e) => setTransferTaxes(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Attorney Fees ($)"
|
||||
type="number"
|
||||
value={attorneyFees}
|
||||
onChange={(e) => setAttorneyFees(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Prorated Property Taxes ($)"
|
||||
type="number"
|
||||
value={proratedPropertyTaxes}
|
||||
onChange={(e) =>
|
||||
setProratedPropertyTaxes(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Prorated HOA Dues ($)"
|
||||
type="number"
|
||||
value={proratedHoaDues}
|
||||
onChange={(e) =>
|
||||
setProratedHoaDues(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Seller Concessions ($)"
|
||||
type="number"
|
||||
value={sellerConcessions}
|
||||
onChange={(e) =>
|
||||
setSellerConcessions(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Other Fees ($)"
|
||||
type="number"
|
||||
value={otherFees}
|
||||
onChange={(e) => setOtherFees(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
inputProps={{ min: 0 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={calculateNetTerms}
|
||||
fullWidth
|
||||
size="large"
|
||||
>
|
||||
Calculate Net Proceeds
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{totalClosingCosts > 0 || netProceeds !== 0 ? ( // Display results if calculations have been made
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 2 }}>
|
||||
Sale Summary
|
||||
</Typography>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
Estimated Total Closing Costs: ${totalClosingCosts.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="h6" color={netProceeds >= 0 ? 'primary' : 'error'} sx={{ mt: 1 }}>
|
||||
Estimated Net Proceeds: ${netProceeds.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
*This is an estimate. Actual closing costs and net proceeds may vary. Consult with
|
||||
your real estate agent or attorney for precise figures.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetTermsSheet;
|
||||
35
ditch-the-agent/src/pages/Upgrade/UpgradePage.tsx
Normal file
35
ditch-the-agent/src/pages/Upgrade/UpgradePage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
|
||||
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
|
||||
import ProfessionalUpgrade from 'components/sections/dashboard/Home/Upgrade/ProfessionalUpgrade';
|
||||
import PropertyOwnerUpgrade from 'components/sections/dashboard/Home/Upgrade/PropertyOwnerUpgrade';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { useContext } from 'react';
|
||||
import { UserAPI } from 'types';
|
||||
|
||||
export type UpgradeProps = {
|
||||
account: UserAPI;
|
||||
};
|
||||
|
||||
const UpgradePage: React.FC = () => {
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
if (accountLoading) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
if (account.user_type === 'property_owner') {
|
||||
return <PropertyOwnerUpgrade onUpgradeClick={() => {}} />;
|
||||
} else if (account.user_type === 'vendor') {
|
||||
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
|
||||
} else if (account.user_type === 'attorney') {
|
||||
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
|
||||
} else if (account.user_type === 'real_estate_agent') {
|
||||
return <ProfessionalUpgrade userType={account.user_type} onUpgradeClick={() => {}} />;
|
||||
//return (<VendorProfile account={account} />)
|
||||
}
|
||||
};
|
||||
|
||||
export default UpgradePage;
|
||||
@@ -1,26 +1,142 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
|
||||
const Vendors = (): ReactElement => {
|
||||
return(
|
||||
<Grid
|
||||
container
|
||||
component="main"
|
||||
columns={12}
|
||||
spacing={3.75}
|
||||
flexGrow={1}
|
||||
pt={4.375}
|
||||
pr={1.875}
|
||||
pb={0}
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
pl: { xs: 3.75, lg: 0 },
|
||||
}}
|
||||
>
|
||||
<p>Vendors</p>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
|
||||
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
|
||||
|
||||
// Simulate fetching data
|
||||
let fetchedVendors: VendorItem[] = [];
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const response = await axiosInstance.get<VendorAPI[]>('/vendors/');
|
||||
if (response.data.length > 0) {
|
||||
fetchedVendors = response.data.map((item) => {
|
||||
return {
|
||||
id: item.user.id,
|
||||
name: item.business_name,
|
||||
categoryId: item.business_type,
|
||||
phone: item.phone_number,
|
||||
email: item.user.email,
|
||||
address: item.address,
|
||||
contactPerson: item.user.first_name + ' ' + item.user.last_name,
|
||||
serviceAreas: item.service_areas,
|
||||
vendorImageUrl: 'https://via.placeholder.com/60?text=SE',
|
||||
rating: 4.5,
|
||||
servicesOffered: ['Residential wiring', 'Commercial electrical', 'Panel upgrades'],
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
};
|
||||
});
|
||||
setAllVendors(fetchedVendors);
|
||||
}
|
||||
|
||||
// Process categories based on fetched vendors
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
numVendors: number;
|
||||
totalRating: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const defaultCategoryImages: { [key: string]: string } = {
|
||||
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician',
|
||||
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber',
|
||||
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping',
|
||||
};
|
||||
|
||||
fetchedVendors.forEach((vendor) => {
|
||||
const categoryId = vendor.categoryId;
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
let categoryName = '';
|
||||
switch (categoryId) {
|
||||
case 'electrician':
|
||||
categoryName = 'Electricians';
|
||||
break;
|
||||
case 'plumber':
|
||||
categoryName = 'Plumbers';
|
||||
break;
|
||||
case 'landscaping':
|
||||
categoryName = 'Landscaping';
|
||||
break;
|
||||
default:
|
||||
categoryName = 'Other Service';
|
||||
}
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
|
||||
imageUrl:
|
||||
defaultCategoryImages[categoryId] ||
|
||||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
numVendors: 0,
|
||||
totalRating: 0,
|
||||
});
|
||||
}
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.numVendors += 1;
|
||||
categoryData.totalRating += vendor.rating;
|
||||
});
|
||||
|
||||
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
|
||||
([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
imageUrl: data.imageUrl,
|
||||
numVendors: data.numVendors,
|
||||
categoryRating: data.numVendors > 0 ? data.totalRating / data.numVendors : undefined,
|
||||
}),
|
||||
);
|
||||
setVendorCategories(processedCategories);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardTemplate<VendorCategory, VendorItem>
|
||||
pageTitle="Service Vendors"
|
||||
data={{ categories: vendorCategories, items: allVendors }}
|
||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||
<CategoryGridTemplate
|
||||
categories={categories}
|
||||
onSelectCategory={(id) => onSelectCategory(id)}
|
||||
renderCategoryCard={(category, onSelect) => (
|
||||
<VendorCategoryCard category={category as VendorCategory} onSelectCategory={onSelect} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||
<ItemListDetailTemplate
|
||||
category={selectedCategory}
|
||||
items={itemsInSelectedCategory}
|
||||
onBack={onBack}
|
||||
renderListItem={(item, isSelected, onSelect) => (
|
||||
<VendorListItem
|
||||
vendor={item as VendorItem}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
renderItemDetail={(item) => (
|
||||
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Vendors;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, Suspense, useState } from 'react';
|
||||
import { ReactElement, Suspense, useContext, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
@@ -15,12 +16,55 @@ import loginBanner from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import{axiosInstance} from '../../axiosApi.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { AuthContext } from 'contexts/AuthContext.js';
|
||||
|
||||
type loginValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
|
||||
const Login = (): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {setAuthentication} = useContext(AuthContext);
|
||||
|
||||
const handleLogin = async({email, password}: loginValues): Promise<void> => {
|
||||
try{
|
||||
const response = await axiosInstance.post('/token/',
|
||||
{
|
||||
email: email,
|
||||
password: password
|
||||
}
|
||||
)
|
||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
localStorage.setItem('refresh_token', response.data.refresh);
|
||||
const get_user_response = await axiosInstance.get('/user/')
|
||||
|
||||
setAuthentication(true)
|
||||
|
||||
navigate("/")
|
||||
|
||||
|
||||
|
||||
}catch (error) {
|
||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||
if (hasErrors) {
|
||||
setErrorMessage(error.response.data)
|
||||
}else{
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -35,67 +79,104 @@ const Login = (): ReactElement => {
|
||||
</Link>
|
||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
||||
<Typography variant="h3">Login</Typography>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
alignSelf: 'flex-end',
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
}}
|
||||
>
|
||||
<Link href="/authentication/forgot-password" underline="hover">
|
||||
Forget password
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant="contained" fullWidth>
|
||||
Log in
|
||||
</Button>
|
||||
onSubmit={handleLogin}
|
||||
>
|
||||
{({setFieldValue}) => (
|
||||
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
{errorMessage ? (
|
||||
<Alert severity='error'>
|
||||
<ul>
|
||||
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||
<li key={fieldName}>
|
||||
<strong>{fieldName}</strong>
|
||||
{errorMessages.length > 0 ? (
|
||||
<ul>
|
||||
{errorMessages.map((message, index) => (
|
||||
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span> No specific errors for this field.</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</Alert>
|
||||
|
||||
): null}
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Link href="/authentication/forgot-password" underline="hover">
|
||||
Forget password
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant="contained" type={'submit'} fullWidth>
|
||||
Log in
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Don't have an account ?{' '}
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactElement, Suspense, useState } from 'react';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { ErrorMessage, Form, Formik } from 'formik';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
@@ -20,7 +21,8 @@ import signupBanner from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import{axiosInstance} from '../../axiosApi.js';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type SignUpValues = {
|
||||
email: string;
|
||||
@@ -29,49 +31,54 @@ type SignUpValues = {
|
||||
password: string;
|
||||
password2: string;
|
||||
ownerType: string;
|
||||
}
|
||||
};
|
||||
|
||||
const SignUp = (): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
|
||||
// const [firstName, setFirstName] = useState('');
|
||||
// const [lastName, setLastName] = useState('');
|
||||
// const [email, setEmail] = useState('');
|
||||
// const [password, setPassword] = useState('');
|
||||
// const [password2, setPassword2] = useState('');
|
||||
// const [ownerType, setOwnerType] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||
|
||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
||||
const handleClickShowPassword2 = () => setShowPassword2(!showPassword2);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignUp = async({email, first_name, last_name, ownerType, password, password2}: SignUpValues): Promise<void> => {
|
||||
|
||||
console.log({
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
user_type: ownerType,
|
||||
password: password,
|
||||
password2: password2
|
||||
})
|
||||
const response = await axiosInstance.post('/api/register',
|
||||
{
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
user_type: ownerType,
|
||||
password: password,
|
||||
password2: password2
|
||||
}
|
||||
)
|
||||
}
|
||||
const handleSignUp = async ({
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
ownerType,
|
||||
password,
|
||||
password2,
|
||||
}: SignUpValues): Promise<void> => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/register/', {
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
user_type: ownerType,
|
||||
password: password,
|
||||
password2: password2,
|
||||
});
|
||||
if (response.status == 201) {
|
||||
navigate('/authentication/login');
|
||||
} else {
|
||||
console.log(`No good: ${response}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||
if (hasErrors) {
|
||||
setErrorMessage(error.response.data);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
bgcolor="background.paper"
|
||||
boxShadow={(theme) => theme.shadows[3]}
|
||||
|
||||
width={{ md: 960 }}
|
||||
>
|
||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||
@@ -87,156 +94,193 @@ const SignUp = (): ReactElement => {
|
||||
last_name: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
ownerType: "property_owner"
|
||||
ownerType: 'property_owner',
|
||||
}}
|
||||
onSubmit={handleSignUp}
|
||||
>
|
||||
{({ setFieldValue }) => (
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
{errorMessage ? (
|
||||
<Alert severity="error">
|
||||
<ul>
|
||||
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||
<li key={fieldName}>
|
||||
<strong>{fieldName}</strong>
|
||||
{errorMessages.length > 0 ? (
|
||||
<ul>
|
||||
{errorMessages.map((message, index) => (
|
||||
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span> No specific errors for this field.</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
) : null}
|
||||
<InputLabel shrink htmlFor="name">
|
||||
First Name
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
onChange={(event) => setFieldValue('first_name', event.target.value)}
|
||||
placeholder="Enter your first name"
|
||||
id="first_name"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="name">
|
||||
Last Name
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
onChange={(event) => setFieldValue('last_name', event.target.value)}
|
||||
placeholder="Enter your last name"
|
||||
id="last_name"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
>
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="name">
|
||||
First Name
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
onChange={(event) => formik.setFieldValue('first_name', event.target.value)}
|
||||
placeholder="Enter your first name"
|
||||
id="first_name"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
endAdornment={
|
||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||
<IconifyIcon icon="ic:baseline-email" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="name">
|
||||
Last Name
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
onChange={(event) => formik.setFieldValue('last_name', event.target.value)}
|
||||
placeholder="Enter your last name"
|
||||
id="last_name"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<OutlinedInput
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
endAdornment={
|
||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||
<IconifyIcon icon="ic:baseline-email" width={1} height={1} />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{
|
||||
width: 1,
|
||||
backgroundColor: 'action.focus',
|
||||
}}
|
||||
onChange={(event) => formik.setFieldValue('email', event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Account Type
|
||||
</InputLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
onChange={(event) => formik.setFieldValue('ownerType', event.target.value)}
|
||||
>
|
||||
<FormControlLabel value="property_owner" control={<Radio />} name="ownerType" label="Owner"/>
|
||||
<FormControlLabel value="vendor" control={<Radio />} name="ownerType" label="Vendor" />
|
||||
|
||||
</RadioGroup>
|
||||
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => formik.setFieldValue('password', event.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Confirm Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => formik.setFieldValue('password2', event.target.value)}
|
||||
type={showPassword2 ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword2}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="contained" type={'submit'} fullWidth>
|
||||
Sign up
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
)}
|
||||
}
|
||||
sx={{
|
||||
width: 1,
|
||||
backgroundColor: 'action.focus',
|
||||
}}
|
||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Account Type
|
||||
</InputLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
onChange={(event) => setFieldValue('ownerType', event.target.value)}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="property_owner"
|
||||
control={<Radio />}
|
||||
name="ownerType"
|
||||
label="Owner"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="vendor"
|
||||
control={<Radio />}
|
||||
name="ownerType"
|
||||
label="Vendor"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="attorney"
|
||||
control={<Radio />}
|
||||
name="ownerType"
|
||||
label="Attorney"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="real_estate_agent"
|
||||
control={<Radio />}
|
||||
name="ownerType"
|
||||
label="Real Estate Agent"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Confirm Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password2', event.target.value)}
|
||||
type={showPassword2 ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword2}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="contained" type={'submit'} fullWidth>
|
||||
Sign up
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Already have an account ?{' '}
|
||||
<Link
|
||||
|
||||
40
ditch-the-agent/src/pages/home/Dashboard.tsx
Normal file
40
ditch-the-agent/src/pages/home/Dashboard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import AttorneyDashboard from 'components/sections/dashboard/Home/Dashboard/AttorneyDashboard';
|
||||
import DashboardErrorPage from 'components/sections/dashboard/Home/Dashboard/DashboardErrorPage';
|
||||
import DashboardLoading from 'components/sections/dashboard/Home/Dashboard/DashboardLoading';
|
||||
import PropertyOwnerDashboard from 'components/sections/dashboard/Home/Dashboard/PropertyOwnerDashboard';
|
||||
import RealEstateAgentDashboard from 'components/sections/dashboard/Home/Dashboard/RealEstateAgentDashboard';
|
||||
import VendorDashboard from 'components/sections/dashboard/Home/Dashboard/VendorDashboard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { ReactElement, useContext } from 'react';
|
||||
import { UserAPI } from 'types';
|
||||
|
||||
export type DashboardProps = {
|
||||
account: UserAPI;
|
||||
};
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
if (accountLoading) {
|
||||
return <DashboardLoading />;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return <DashboardErrorPage />;
|
||||
}
|
||||
|
||||
//change what to show based on the account type
|
||||
if (account.user_type === 'property_owner') {
|
||||
return <PropertyOwnerDashboard account={account} />;
|
||||
} else if (account.user_type === 'vendor') {
|
||||
return <VendorDashboard account={account} />;
|
||||
} else if (account.user_type === 'attorney') {
|
||||
return <AttorneyDashboard account={account} />;
|
||||
} else if (account.user_type === 'real_estate_agent') {
|
||||
return <RealEstateAgentDashboard account={account} />;
|
||||
} else {
|
||||
return <p>404 error</p>;
|
||||
}
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -1,65 +0,0 @@
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import { Stack } from '@mui/material';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import TopSellingProduct from 'components/sections/dashboard/Home/Sales/TopSellingProduct/TopSellingProduct';
|
||||
import WebsiteVisitors from 'components/sections/dashboard/Home/Sales/WebsiteVisitors/WebsiteVisitors';
|
||||
import SaleInfoCards from 'components/sections/dashboard/Home/Sales/SaleInfoSection/SaleInfoCards';
|
||||
import BuyersProfile from 'components/sections/dashboard/Home/Sales/BuyersProfile/BuyersProfile';
|
||||
import NewCustomers from 'components/sections/dashboard/Home/Sales/NewCustomers/NewCustomers';
|
||||
import Revenue from 'components/sections/dashboard/Home/Sales/Revenue/Revenue';
|
||||
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import {EducationInfoCards} from 'components/sections/dashboard/Home/Education/EducationInfo';
|
||||
import { ProperyInfoCards } from 'components/sections/dashboard/Home/Property/PropertyInfo';
|
||||
|
||||
const Sales = (): ReactElement => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
component="main"
|
||||
columns={12}
|
||||
spacing={3.75}
|
||||
flexGrow={1}
|
||||
pt={4.375}
|
||||
pr={1.875}
|
||||
pb={0}
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
pl: { xs: 3.75, lg: 0 },
|
||||
}}
|
||||
>
|
||||
<Grid xs={12} md={4}>
|
||||
<ProperyInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<EducationInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<SaleInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<Revenue />
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<WebsiteVisitors />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={8}>
|
||||
<TopSellingProduct />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={4}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
|
||||
gap={3.75}
|
||||
height={1}
|
||||
width={1}
|
||||
>
|
||||
<NewCustomers />
|
||||
<BuyersProfile />
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sales;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, Suspense, useState } from 'react';
|
||||
import { ReactElement, Suspense, useContext, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
@@ -11,119 +11,220 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import loginBanner from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
|
||||
const Login = (): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { UserAPI } from 'types';
|
||||
|
||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
||||
const TermsOfService = (): ReactElement => {
|
||||
const [errors, setErrors] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { account, setAccount, accountLoading } = useContext(AccountContext);
|
||||
|
||||
const handleSignTOS = async (): Promise<void> => {
|
||||
console.log('handle tos signing');
|
||||
try {
|
||||
const { data }: AxiosResponse<UserAPI> = await axiosInstance.put('/user/acknowledge_tos/');
|
||||
if (data) {
|
||||
setAccount(data);
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
setErrors(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
bgcolor="background.paper"
|
||||
boxShadow={(theme) => theme.shadows[3]}
|
||||
height={560}
|
||||
minHeight={560}
|
||||
width={{ md: 960 }}
|
||||
>
|
||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||
<Link href="/" height="fit-content">
|
||||
<Image src={logo} width={82.6} />
|
||||
</Link>
|
||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
||||
<Typography variant="h3">Login</Typography>
|
||||
<Stack m={2.5} gap={10}>
|
||||
<Stack alignItems="center" gap={2.5} mx="auto">
|
||||
<Typography variant="h1">Terms Of Service</Typography>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption">Last Updated: July 15, 2025</Typography>
|
||||
<Typography variant="body1">
|
||||
Welcome to [Your Website Name]! These Terms of Service ("Terms") govern your access to
|
||||
and use of the [Your Website Name] website portal (the "Service"), operated by [Your
|
||||
Company Name] ("we," "us," or "our").
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
By accessing or using the Service, you agree to be bound by these Terms and our
|
||||
Privacy Policy. If you do not agree to these Terms, please do not use the Service.
|
||||
</Typography>
|
||||
<Typography>
|
||||
<Typography variant="h4">1. Acceptance of Terms</Typography>
|
||||
<Typography variant="body1">
|
||||
By creating an account, accessing, or using the Service, you signify your agreement
|
||||
to these Terms. You must be at least 18 years old or the age of legal majority in
|
||||
your jurisdiction to use the Service. If you are accessing or using the Service on
|
||||
behalf of a company or other legal entity, you represent that you have the authority
|
||||
to bind such entity to these Terms.
|
||||
</Typography>
|
||||
<Typography variant="h4">2. Changes to Terms</Typography>
|
||||
<Typography variant="body1">
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at
|
||||
any time. If a revision is material, we will provide at least 30 days' notice prior
|
||||
to any new terms taking effect. What constitutes a material change will be
|
||||
determined at our sole discretion. By continuing to access or use our Service after
|
||||
those revisions become effective, you agree to be bound by the revised terms.
|
||||
</Typography>
|
||||
<Typography variant="h4">3. Privacy Policy</Typography>
|
||||
<Typography variant="body1">
|
||||
Your use of the Service is also governed by our Privacy Policy, which explains how
|
||||
we collect, use, and disclose information about you. Please review our Privacy
|
||||
Policy at [Link to your Privacy Policy] to understand our practices.
|
||||
</Typography>
|
||||
<Typography variant="h4">4. User Accounts</Typography>
|
||||
<Typography variant="body1">
|
||||
Account Creation: To access certain features of the Service, you may be required to
|
||||
register for an account. You agree to provide accurate, current, and complete
|
||||
information during the registration process and to update such information to keep
|
||||
it accurate, current, and complete.
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Account Security: You are responsible for safeguarding the password that you use to
|
||||
access the Service and for any activities or actions under your password. You agree
|
||||
not to disclose your password to any third party. You must notify us immediately
|
||||
upon becoming aware of any breach of security or unauthorized use of your account.
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Account Termination: We may suspend or terminate your account and access to the
|
||||
Service immediately, without prior notice or liability, for any reason whatsoever,
|
||||
including without limitation if you breach the Terms.
|
||||
</Typography>
|
||||
<Typography variant="h4">5. User Conduct</Typography>
|
||||
<Typography variant="body1">
|
||||
You agree to use the Service only for lawful purposes and in a way that does not
|
||||
infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the
|
||||
Service. Prohibited behavior includes harassing or causing distress or inconvenience
|
||||
to any other user, transmitting obscene or offensive content, or disrupting the
|
||||
normal flow of dialogue within the Service.
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
You agree not to:
|
||||
<ul>
|
||||
<li>
|
||||
Use the Service in any way that violates any applicable national or
|
||||
international law or regulation.
|
||||
</li>
|
||||
<li>
|
||||
Engage in any conduct that restricts or inhibits anyone's use or enjoyment of
|
||||
the Service, or which, as determined by us, may harm us or users of the Service
|
||||
or expose them to liability.
|
||||
</li>
|
||||
<li>
|
||||
Use the Service to transmit any unsolicited or unauthorized advertising or
|
||||
promotional material or any other form of similar solicitation (spam).
|
||||
</li>
|
||||
<li>
|
||||
Impersonate or attempt to impersonate [Your Company Name], a [Your Company Name]
|
||||
employee, another user, or any other person or entity.
|
||||
</li>
|
||||
<li>
|
||||
Introduce any viruses, Trojan horses, worms, logic bombs, or other material that
|
||||
is malicious or technologically harmful.
|
||||
</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
<Typography variant="h4">6. Intellectual Property</Typography>
|
||||
The Service and its original content (excluding content provided by users), features,
|
||||
and functionality are and will remain the exclusive property of [Your Company Name]
|
||||
and its licensors. The Service is protected by copyright, trademark, and other laws of
|
||||
both the [Your Country] and foreign countries. Our trademarks and trade dress may not
|
||||
be used in connection with any product or service without the prior written consent of
|
||||
[Your Company Name].
|
||||
<Typography variant="h4">7. User-Generated Content</Typography>
|
||||
If the Service allows you to post, link, store, share, and otherwise make available
|
||||
certain information, text, graphics, videos, or other material ("Content"), you are
|
||||
responsible for the Content that you post on or through the Service, including its
|
||||
legality, reliability, and appropriateness. By posting Content to the Service, you
|
||||
grant us the right and license to use, modify, publicly perform, publicly display,
|
||||
reproduce, and distribute such Content on and through the Service. You retain any and
|
||||
all of your rights to any Content you submit, post or display on or through the
|
||||
Service and you are responsible for protecting those rights. You represent and warrant
|
||||
that: (i) the Content is yours (you own it) or you have the right to use it and grant
|
||||
us the rights and license as provided in these Terms, and (ii) the posting of your
|
||||
Content on or through the Service does not violate the privacy rights, publicity
|
||||
rights, copyrights, contract rights or any other rights of any person.
|
||||
<Typography variant="h4">8. Links to Other Websites</Typography>
|
||||
Our Service may contain links to third-party web sites or services that are not owned
|
||||
or controlled by [Your Company Name]. [Your Company Name] has no control over, and
|
||||
assumes no responsibility for, the content, privacy policies, or practices of any
|
||||
third-party web sites or services. You further acknowledge and agree that [Your
|
||||
Company Name] shall not be responsible or liable, directly or indirectly, for any
|
||||
damage or loss caused or alleged to be caused by or in connection with use of or
|
||||
reliance on any such content, goods or services available on or through any such web
|
||||
sites or services. We strongly advise you to read the terms and conditions and privacy
|
||||
policies of any third-party web sites or services that you visit.
|
||||
<Typography variant="h4">9. Disclaimer of Warranties</Typography>
|
||||
Your use of the Service is at your sole risk. The Service is provided on an "AS IS"
|
||||
and "AS AVAILABLE" basis. The Service is provided without warranties of any kind,
|
||||
whether express or implied, including, but not limited to, implied warranties of
|
||||
merchantability, fitness for a particular purpose, non-infringement or course of
|
||||
performance. [Your Company Name] its subsidiaries, affiliates, and its licensors do
|
||||
not warrant that a) the Service will function uninterrupted, secure or available at
|
||||
any particular time or location; b) any errors or defects will be corrected; c) the
|
||||
Service is free of viruses or other harmful components; or d) the results of using the
|
||||
Service will meet your requirements.
|
||||
<Typography variant="h4">10. Limitation of Liability</Typography>
|
||||
In no event shall [Your Company Name], nor its directors, employees, partners, agents,
|
||||
suppliers, or affiliates, be liable for any indirect, incidental, special,
|
||||
consequential or punitive damages, including without limitation, loss of profits,
|
||||
data, use, goodwill, or other intangible losses, resulting from (i) your access to or
|
||||
use of or inability to access or use the Service; (ii) any conduct or content of any
|
||||
third party on the Service; (iii) any content obtained from the Service; and (iv)
|
||||
unauthorized access, use or alteration of your transmissions or content, whether based
|
||||
on warranty, contract, tort (including negligence) or any other legal theory, whether
|
||||
or not we have been informed of the possibility of such damage, and even if a remedy
|
||||
set forth herein is found to have failed of its essential purpose.
|
||||
<Typography variant="h4">11. Indemnification</Typography>
|
||||
You agree to defend, indemnify and hold harmless [Your Company Name] and its licensee
|
||||
and licensors, and their employees, contractors, agents, officers and directors, from
|
||||
and against any and all claims, damages, obligations, losses, liabilities, costs or
|
||||
debt, and expenses (including but not limited to attorney's fees), resulting from or
|
||||
arising out of a) your use and access of the Service, by you or any person using your
|
||||
account and password; b) a breach of these Terms; or c) Content posted on the Service.
|
||||
<Typography variant="h4">12. Governing Law</Typography>
|
||||
These Terms shall be governed and construed in accordance with the laws of [Your
|
||||
State/Country], without regard to its conflict of law provisions. Our failure to
|
||||
enforce any right or provision of these Terms will not be considered a waiver of those
|
||||
rights. If any provision of these Terms is held to be invalid or unenforceable by a
|
||||
court, the remaining provisions of these Terms will remain in effect. These Terms
|
||||
constitute the entire agreement between us regarding our Service, and supersede and
|
||||
replace any prior agreements we might have between us regarding the Service.
|
||||
<Typography variant="h4">13. Contact Us</Typography>
|
||||
If you have any questions about these Terms, please contact us: By email: [Your Email
|
||||
Address] By visiting this page on our website: [Link to your Contact Us page]
|
||||
</Typography>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Link href="/authentication/forgot-password" underline="hover">
|
||||
Forget password
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant="contained" fullWidth>
|
||||
Log in
|
||||
></Typography>
|
||||
|
||||
<Button variant="contained" fullWidth onClick={handleSignTOS}>
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Don't have an account ?{' '}
|
||||
<Link
|
||||
href="/authentication/sign-up"
|
||||
underline="hover"
|
||||
fontSize={(theme) => theme.typography.body1.fontSize}
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||
}
|
||||
>
|
||||
<Image
|
||||
alt="Login banner"
|
||||
src={loginBanner}
|
||||
sx={{
|
||||
width: 0.5,
|
||||
display: { xs: 'none', md: 'block' },
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
></Suspense>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default TermsOfService;
|
||||
|
||||
@@ -6,12 +6,18 @@ export const rootPaths = {
|
||||
authRoot: 'authentication',
|
||||
notificationsRoot: 'notifications',
|
||||
calendarRoot: 'calendar',
|
||||
messageRoot: 'messages',
|
||||
messageRoot: 'conversations',
|
||||
errorRoot: 'error',
|
||||
educationRoot: 'education',
|
||||
propertyRoot: 'property',
|
||||
vendorsRoot: 'vendors',
|
||||
termsOfServiceRoot: 'terms-of-service',
|
||||
toolsRoot: 'tools',
|
||||
profileRoot: 'profile',
|
||||
offersRoot: 'offers',
|
||||
bidsRoot: 'bids',
|
||||
vendorBidsRoot: 'vendor-bids',
|
||||
upgradeRoot: 'upgrade',
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -24,7 +30,20 @@ export default {
|
||||
education: `/${rootPaths.educationRoot}`,
|
||||
educationLesson: `/${rootPaths.educationRoot}/lesson`,
|
||||
property: `/${rootPaths.propertyRoot}`,
|
||||
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
|
||||
propertySearch: `/${rootPaths.propertyRoot}/search`,
|
||||
vendors: `/${rootPaths.vendorsRoot}`,
|
||||
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
|
||||
};
|
||||
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
|
||||
amoritizationTable: `/${rootPaths.toolsRoot}/amoritization-table`,
|
||||
homeAffordability: `/${rootPaths.toolsRoot}/home-affordability`,
|
||||
netTermsSheet: `/${rootPaths.toolsRoot}/net-terms-sheet`,
|
||||
messages: `/${rootPaths.messageRoot}/`,
|
||||
offers: `/${rootPaths.offersRoot}/`,
|
||||
bids: `/${rootPaths.bidsRoot}/`,
|
||||
vendorBids: `/${rootPaths.vendorBidsRoot}/`,
|
||||
upgrade: `/${rootPaths.upgradeRoot}/`,
|
||||
|
||||
// need to do these pages
|
||||
profile: `/${rootPaths.profileRoot}/`,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ReactNode, Suspense, lazy, useContext } from 'react';
|
||||
import { Navigate, Outlet, RouteObject, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
RouteObject,
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import paths, { rootPaths } from './paths';
|
||||
|
||||
@@ -11,6 +18,20 @@ import Vendors from 'pages/Vendors/Vendors';
|
||||
import EducationDetail from 'components/sections/dashboard/Home/Education/EducationDetail';
|
||||
import TermsOfService from 'pages/home/TermsOfService';
|
||||
import { AuthContext, AuthProvider } from 'contexts/AuthContext';
|
||||
import AmoritizationTable from 'pages/Tools/AmoritizationTable';
|
||||
import MortgageCalculator from 'pages/Tools/MortgageCalculator';
|
||||
import HomeAffordability from 'pages/Tools/HomeAffordability';
|
||||
import Messages from 'pages/Messages/Messages';
|
||||
import Offers from 'pages/Offers/Offers';
|
||||
import NetTermsSheet from 'pages/Tools/NetTermsSheet';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import ProfilePage from 'pages/Profile/Profile';
|
||||
import Dashboard from 'pages/home/Dashboard';
|
||||
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
|
||||
import PropertySearchPage from 'pages/Property/PropertySearchPage';
|
||||
import BidsPage from 'pages/Bids/Bids';
|
||||
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
|
||||
import UpgradePage from 'pages/Upgrade/UpgradePage';
|
||||
|
||||
const App = lazy(() => import('App'));
|
||||
const MainLayout = lazy(async () => {
|
||||
@@ -31,13 +52,6 @@ const Error404 = lazy(async () => {
|
||||
return import('pages/errors/Error404');
|
||||
});
|
||||
|
||||
const Sales = lazy(async () => {
|
||||
return Promise.all([
|
||||
import('pages/home/Sales'),
|
||||
new Promise((resolve) => setTimeout(resolve, 500)),
|
||||
]).then(([moduleExports]) => moduleExports);
|
||||
});
|
||||
|
||||
const Login = lazy(async () => import('pages/authentication/Login'));
|
||||
const SignUp = lazy(async () => import('pages/authentication/SignUp'));
|
||||
|
||||
@@ -46,16 +60,27 @@ const ForgotPassword = lazy(async () => import('pages/authentication/ForgotPassw
|
||||
|
||||
type ProtectedRouteProps = {
|
||||
children?: ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
const { authenticated, loading } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const { authenticated, loading } = useContext(AuthContext);
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
if (!authenticated && !loading) {
|
||||
return <Navigate to="/authentication/login" replace />;
|
||||
if (!loading) {
|
||||
if (!authenticated) {
|
||||
return <Navigate to="/authentication/login" replace />;
|
||||
} else if (!accountLoading && account) {
|
||||
console.log(account);
|
||||
if (!account.tos_signed) {
|
||||
console.log('go to tos');
|
||||
//navigate('/terms-of-service');
|
||||
return <Navigate to="/terms-of-service" replace />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
return children;
|
||||
};
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
@@ -80,7 +105,8 @@ const routes: RouteObject[] = [
|
||||
children: [
|
||||
{
|
||||
path: paths.home,
|
||||
element: <Sales />,
|
||||
element: <Dashboard />,
|
||||
//element: <Sales />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,65 +155,234 @@ const routes: RouteObject[] = [
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
path: rootPaths.propertyRoot,
|
||||
path: rootPaths.propertyRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.property,
|
||||
element: <Property />,
|
||||
},
|
||||
],
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.property,
|
||||
element: <Property />,
|
||||
},
|
||||
{
|
||||
path: paths.propertyDetail,
|
||||
element: <PropertyDetailPage />,
|
||||
},
|
||||
{
|
||||
path: paths.propertySearch,
|
||||
element: <PropertySearchPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.educationRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.education,
|
||||
element: <Education />,
|
||||
},
|
||||
{
|
||||
path: paths.educationLesson,
|
||||
element: <EducationDetail />,
|
||||
},
|
||||
],
|
||||
path: rootPaths.termsOfServiceRoot,
|
||||
element: (
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.termsOfService,
|
||||
element: <TermsOfService />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.vendorsRoot,
|
||||
path: rootPaths.educationRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.vendors,
|
||||
element: <Vendors />,
|
||||
},
|
||||
],
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.education,
|
||||
element: <Education />,
|
||||
},
|
||||
{
|
||||
path: paths.educationLesson,
|
||||
element: <EducationDetail />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.vendorsRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.vendors,
|
||||
element: <Vendors />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: rootPaths.offersRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.offers,
|
||||
element: <Offers />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.bidsRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.bids,
|
||||
element: <BidsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.vendorBidsRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.vendorBids,
|
||||
element: <VendorBidsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.messageRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.messages,
|
||||
element: <Messages />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.profileRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.profile,
|
||||
element: <ProfilePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.upgradeRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.upgrade,
|
||||
element: <UpgradePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.toolsRoot,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.homeAffordability,
|
||||
element: <HomeAffordability />,
|
||||
},
|
||||
{
|
||||
path: paths.mortageCalculator,
|
||||
element: <MortgageCalculator />,
|
||||
},
|
||||
{
|
||||
path: paths.amoritizationTable,
|
||||
element: <AmoritizationTable />,
|
||||
},
|
||||
{
|
||||
path: paths.netTermsSheet,
|
||||
element: <NetTermsSheet />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
@@ -197,7 +392,6 @@ const routes: RouteObject[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const router = createBrowserRouter(routes, { basename: '/elegent' });
|
||||
|
||||
const router = createBrowserRouter(routes, { basename: '' });
|
||||
|
||||
export default router;
|
||||
|
||||
714
ditch-the-agent/src/types.ts
Normal file
714
ditch-the-agent/src/types.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
// src/templates/types.ts
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
active: boolean;
|
||||
collapsible: boolean;
|
||||
sublist?: NavItem[];
|
||||
}
|
||||
|
||||
// A generic "Category" type
|
||||
export interface GenericCategory {
|
||||
id: string; // Unique identifier for the category
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl?: string; // Optional image for the category card
|
||||
// Add any other common category properties here
|
||||
}
|
||||
|
||||
// A generic "Item" type (e.g., a Video, a Vendor)
|
||||
export interface GenericItem {
|
||||
id: number; // Unique identifier for the item
|
||||
name: string; // Corresponds to title for video, name for vendor
|
||||
description: string; // Corresponds to description for video, short description for vendor
|
||||
// Add any other common item properties here
|
||||
}
|
||||
|
||||
// Data structure for the main template
|
||||
export interface DashboardData<TCategory extends GenericCategory, TItem extends GenericItem> {
|
||||
categories: TCategory[];
|
||||
items: TItem[]; // All items, will be filtered by category within the template
|
||||
}
|
||||
|
||||
export interface VideoCategory extends GenericCategory {
|
||||
totalVideos: number;
|
||||
completedVideos: number;
|
||||
categoryProgress: number; // Calculated
|
||||
}
|
||||
|
||||
export interface VideoItem extends GenericItem {
|
||||
videoUrl: string;
|
||||
progress_id: number;
|
||||
status: 'completed' | 'in-progress' | 'not-started';
|
||||
progress: number; // 0-100 for individual video progress
|
||||
category: string; // Link back to category
|
||||
categoryId: string; // Link back to category
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// src/pages/VendorDashboardPage.tsx
|
||||
|
||||
// Specific interfaces for Vendor App
|
||||
export interface VendorCategory extends GenericCategory {
|
||||
numVendors: number; // Example specific stat
|
||||
categoryRating?: number; // Example specific stat
|
||||
}
|
||||
|
||||
export interface VendorItem extends GenericItem {
|
||||
contactPerson: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
vendorImageUrl: string;
|
||||
rating: number;
|
||||
servicesOffered: string[];
|
||||
serviceAreas: string[];
|
||||
categoryId: string; // Link back to category
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface MessageItem extends GenericItem {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConversationItem extends GenericItem {
|
||||
messages: MessageItem[];
|
||||
}
|
||||
|
||||
// API Types
|
||||
export interface MessagesAPI {
|
||||
id: number;
|
||||
conversation: number;
|
||||
sender: number;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface UserAPI {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
user_type: 'property_owner' | 'vendor' | 'attorney' | 'real_estate_agent';
|
||||
is_active: boolean;
|
||||
date_joined: string;
|
||||
tos_signed: boolean;
|
||||
profile_created: boolean;
|
||||
tier: 'basic' | 'premium';
|
||||
}
|
||||
export interface PropertyOwnerAPI {
|
||||
user: UserAPI;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export interface VendorAPI {
|
||||
user: UserAPI;
|
||||
business_name: string;
|
||||
business_type: 'electrician' | 'carpenter' | 'plumber' | 'inspector' | 'lendor' | 'other';
|
||||
phone_number: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
|
||||
// New fields for vendor profile
|
||||
description: string; // A brief description of the vendor's business
|
||||
website?: string; // Optional: Vendor's website URL
|
||||
services: string[]; // List of services offered (e.g., ['Residential Wiring', 'Commercial Lighting'])
|
||||
service_areas: string[]; // Areas where the vendor provides service (e.g., ['Chicago', 'Naperville'])
|
||||
certifications?: string[]; // Optional: List of certifications or licenses
|
||||
average_rating?: number; // Optional: Average rating from reviews
|
||||
num_reviews?: number; // Optional: Number of reviews
|
||||
profile_picture?: string; // Optional: URL to a profile picture/logo
|
||||
latitude: number; // For Google Map
|
||||
longitude: number; // For Google Map
|
||||
views: number;
|
||||
}
|
||||
|
||||
export interface AttorneyAPI {
|
||||
user: UserAPI;
|
||||
firm_name: string;
|
||||
bar_number: string; // Unique identifier for attorneys
|
||||
phone_number: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
specialties: string[]; // e.g., ['Real Estate Law', 'Contract Law', 'Litigation']
|
||||
years_experience: number;
|
||||
website?: string;
|
||||
profile_picture?: string;
|
||||
bio?: string; // Short biography
|
||||
licensed_states: string[]; // States where the attorney is licensed
|
||||
latitude: number; // For Google Map
|
||||
longitude: number; // For Google Map
|
||||
}
|
||||
|
||||
export interface RealEstateAgentAPI {
|
||||
user: UserAPI;
|
||||
brokerage_name: string;
|
||||
license_number: string; // Real estate license number
|
||||
phone_number: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
specialties: string[]; // e.g., ['Residential Sales', 'Commercial Leasing', 'First-Time Buyers']
|
||||
years_experience: number;
|
||||
website?: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
licensed_states: string[]; // States where the agent is licensed
|
||||
agent_type: 'buyer_agent' | 'seller_agent' | 'dual_agent' | 'other';
|
||||
latitude: number; // For Google Map
|
||||
longitude: number; // For Google Map
|
||||
}
|
||||
|
||||
export interface ConverationAPI {
|
||||
id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
proptery: number;
|
||||
messages: MessagesAPI[];
|
||||
vendor: VendorAPI;
|
||||
property_owner: PropertyOwnerAPI;
|
||||
}
|
||||
|
||||
export interface VideoCategoryAPI {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface VideoAPI {
|
||||
id: number;
|
||||
category: VideoCategoryAPI;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
duration: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VideoProgressAPI {
|
||||
id: number;
|
||||
video: VideoAPI;
|
||||
progress: number;
|
||||
status: string;
|
||||
last_watched: string;
|
||||
user: number;
|
||||
}
|
||||
|
||||
export interface PictureAPI {
|
||||
id: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface SaleHistoryAPI {
|
||||
seq_no: number;
|
||||
sale_date: string;
|
||||
sale_amount: number;
|
||||
}
|
||||
|
||||
export interface TaxHistoryAPI {
|
||||
assessed_value: number;
|
||||
assessment_year: number;
|
||||
tax_amount: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface OpenHouseAPI {
|
||||
lsited_date: string;
|
||||
}
|
||||
|
||||
export interface SchoolAPI {
|
||||
id?: number;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
created_at?: string;
|
||||
last_updated?: string;
|
||||
latitude: number; // For Google Map
|
||||
longitude: number; // For Google Map
|
||||
school_type: 'public' | 'other';
|
||||
enrollment: number;
|
||||
grades: string;
|
||||
name: string;
|
||||
parent_rating: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface WalkScoreAPI {
|
||||
walk_score: number;
|
||||
walk_description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
ws_link: string;
|
||||
logo_url: string;
|
||||
transit_score: number;
|
||||
transit_description: string;
|
||||
transit_summary: string;
|
||||
bike_score: number;
|
||||
bike_description: string;
|
||||
}
|
||||
|
||||
export interface PropertiesAPI {
|
||||
id: number;
|
||||
owner: PropertyOwnerAPI;
|
||||
address: string; // full address
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
market_value: string;
|
||||
loan_amount: string;
|
||||
loan_term: number;
|
||||
loan_start_date: string;
|
||||
created_at: string;
|
||||
last_updated: string;
|
||||
//
|
||||
|
||||
pictures: PictureAPI[]; // Array of image URLs
|
||||
description: string;
|
||||
sq_ft: number;
|
||||
features: string[]; // e.g., ['Pool', 'Garage', 'Garden']
|
||||
num_bedrooms: number;
|
||||
num_bathrooms: number;
|
||||
latitude?: number; // For Google Map
|
||||
longitude?: number; // For Google Map
|
||||
realestate_api_id: number;
|
||||
|
||||
// New Fields for Status Card
|
||||
property_status: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
|
||||
listed_price?: string;
|
||||
views: number;
|
||||
saves: number;
|
||||
listed_date?: string;
|
||||
|
||||
// New Fields for other cards
|
||||
walk_score?: WalkScoreAPI;
|
||||
sale_info?: SaleHistoryAPI[];
|
||||
tax_info: TaxHistoryAPI;
|
||||
open_houses?: OpenHouseAPI[];
|
||||
schools: SchoolAPI[];
|
||||
}
|
||||
|
||||
export interface BidImageAPI {
|
||||
id: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface BidResponseAPI {
|
||||
id: number;
|
||||
bid: number;
|
||||
vendor: VendorAPI;
|
||||
description: string;
|
||||
price: string;
|
||||
status: 'draft' | 'submitted' | 'selected';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BidAPI {
|
||||
id: number;
|
||||
property: number;
|
||||
description: string;
|
||||
bid_type: 'electrical' | 'plumbing' | 'carpentry' | 'general_contractor';
|
||||
location: 'living_room' | 'basement' | 'kitchen' | 'bathroom' | 'bedroom' | 'outside';
|
||||
images: BidImageAPI[];
|
||||
responses: BidResponseAPI[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
|
||||
owner: number;
|
||||
}
|
||||
|
||||
export interface OfferAPI {
|
||||
id: number;
|
||||
user: UserAPI;
|
||||
property: PropertiesAPI;
|
||||
previous_offer: OfferAPI;
|
||||
status: 'draft' | 'submitted' | 'accepted' | 'rejected' | 'countered';
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// REAL ESTATE API Type Definitions
|
||||
export interface AutocompleteResponseAPI {
|
||||
input: {
|
||||
search: string;
|
||||
};
|
||||
data: AutocompleteDataResponseAPI[];
|
||||
totalResults: number;
|
||||
returnedResults: number;
|
||||
statusCode: number;
|
||||
statusMessage: string;
|
||||
live: boolean;
|
||||
requestExecutionTimeMS: string;
|
||||
}
|
||||
export interface AutocompleteDataResponseAPI {
|
||||
zip: string;
|
||||
address: string;
|
||||
city: string;
|
||||
searchType: string;
|
||||
stateId: string;
|
||||
latitude: number;
|
||||
county: string;
|
||||
fips: string;
|
||||
title: string;
|
||||
house: string;
|
||||
unit?: string;
|
||||
countyId: string;
|
||||
street: string;
|
||||
location: string;
|
||||
id: string;
|
||||
state: string;
|
||||
apn: string;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataMortgageAPI {
|
||||
amount: number;
|
||||
assumable: boolean;
|
||||
book?: string;
|
||||
page?: string;
|
||||
documentNumber: string;
|
||||
deedType: string;
|
||||
documentDate: string;
|
||||
granteeName: string;
|
||||
interestRate: number;
|
||||
interestRateType?: string;
|
||||
lenderCode: string;
|
||||
lenderName: string;
|
||||
lenderType: string;
|
||||
loanType: string;
|
||||
loanTypeCode: string;
|
||||
maturityDate: string;
|
||||
mortgageId: string;
|
||||
position: string;
|
||||
recordingDate: string;
|
||||
seqNo: number;
|
||||
term: number;
|
||||
termType: string;
|
||||
transactionType?: string;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataDemographicsAPI {
|
||||
fmrEfficiency: string;
|
||||
fmrFourBedroom: string;
|
||||
fmrOneBedroom: string;
|
||||
fmrThreeBedroom: string;
|
||||
fmrTwoBedroom: string;
|
||||
fmrYear: string;
|
||||
hudAreaCode: string;
|
||||
hudAreaName: string;
|
||||
medianIncome: string;
|
||||
suggestedRent: string;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataSaleAPI {
|
||||
book?: string;
|
||||
page?: string;
|
||||
documentNumber: string;
|
||||
armsLength: boolean;
|
||||
buyerNames: string;
|
||||
documentType: string;
|
||||
documentTypeCode: string;
|
||||
downPayment: number;
|
||||
ltv: number;
|
||||
ownerIndividual: boolean;
|
||||
priorOwnerIndividual: boolean;
|
||||
priorOwnerMonthsOwned: number;
|
||||
purchaseMethod: string;
|
||||
recordingDate: string;
|
||||
saleAmount: number;
|
||||
saleDate: string;
|
||||
sellerNames: string;
|
||||
seqNo: number;
|
||||
transactionType: string;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataLotInfoAPI {}
|
||||
|
||||
export interface PropertyResponseDataMortgageHistoryAPI {}
|
||||
|
||||
export interface PropertyResponseDataOnwerInfoAPI {
|
||||
mailAddress: {
|
||||
address: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataPropertyInfoAPI {
|
||||
address: {
|
||||
address: string;
|
||||
carrierRoute: string;
|
||||
city: string;
|
||||
congressionalDistrict: string;
|
||||
county: string;
|
||||
fips: string;
|
||||
house: string;
|
||||
jurisdiction: string;
|
||||
label: string;
|
||||
preDirection?: string;
|
||||
state: string;
|
||||
street: string;
|
||||
streetType: string;
|
||||
unit: string;
|
||||
unitType?: string;
|
||||
zip: string;
|
||||
zip4: string;
|
||||
};
|
||||
airConditioningType?: string;
|
||||
attic: boolean;
|
||||
basementFinishedPercent?: number;
|
||||
basementSquareFeet: number;
|
||||
basementSquareFeetFinished: number;
|
||||
basementSquareFeetUnfinished: number;
|
||||
basementType: string;
|
||||
bathrooms: number;
|
||||
bedrooms: number;
|
||||
breezeway: boolean;
|
||||
buildingSquareFeet: number;
|
||||
buildingsCount: number;
|
||||
carport: boolean;
|
||||
construction?: string;
|
||||
deck: boolean;
|
||||
deckArea: number;
|
||||
featureBalcony: boolean;
|
||||
fireplace: boolean;
|
||||
fireplaces?: string;
|
||||
garageSquareFeet: number;
|
||||
garageType: string;
|
||||
heatingFuelType: string;
|
||||
heatingType: string;
|
||||
hoa: boolean;
|
||||
interiorStructure?: string;
|
||||
latitude: number;
|
||||
livingSquareFeet: number;
|
||||
longitude: number;
|
||||
lotSquareFeet: number;
|
||||
parcelAccountNumber?: number;
|
||||
parkingSpaces: number;
|
||||
partialBathrooms: number;
|
||||
patio: boolean;
|
||||
patioArea: string;
|
||||
plumbingFixturesCount: number;
|
||||
pool: boolean;
|
||||
poolArea: number;
|
||||
porchArea: number;
|
||||
porchType: string;
|
||||
pricePerSquareFoot: number;
|
||||
propertyUse: string;
|
||||
propertyUseCode: number;
|
||||
roofConstruction?: string;
|
||||
roofMaterial?: number;
|
||||
roomsCount: number;
|
||||
rvParking: boolean;
|
||||
safetyFireSprinklers: boolean;
|
||||
stories: number;
|
||||
taxExemptionHomeownerFlag: boolean;
|
||||
unitsCount: number;
|
||||
utilitiesSewageUsage?: string;
|
||||
utilitiesWaterSource?: string;
|
||||
yearBuilt: number;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataTaxInfoAPI {
|
||||
assessedImprovementValue: number;
|
||||
assessedLandValue: number;
|
||||
assessedValue: number;
|
||||
assessmentYear: number;
|
||||
estimatedValue?: number;
|
||||
marketImprovementValue: number;
|
||||
marketLandValue: number;
|
||||
marketValue: number;
|
||||
propertyId: number;
|
||||
taxAmount: string;
|
||||
taxDelinquentYear?: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataCompsAPI {
|
||||
id: string;
|
||||
vacant: boolean;
|
||||
absenteeOwner: boolean;
|
||||
corporateOwned: boolean;
|
||||
outOfStateAbsenteeOwner: boolean;
|
||||
inStateAbsenteeOwner: boolean;
|
||||
propertyId: string;
|
||||
bedrooms: number;
|
||||
bathrooms: number;
|
||||
yearBuilt: string;
|
||||
squareFeet: string;
|
||||
estimatedValue: string;
|
||||
equityPercent: string;
|
||||
lastSaleDate: string;
|
||||
lastSaleAmount: string;
|
||||
mlsListingDate: string;
|
||||
mlsLastStatusDate: string;
|
||||
mlsLastSaleDate: string;
|
||||
mlsDaysOnMarket: string;
|
||||
mlsSoldPrice: number;
|
||||
lotSquareFeet: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
openMortgageBalance: string;
|
||||
landUse: string;
|
||||
propertyType: string;
|
||||
propertyUse: string;
|
||||
propertyUseCode: string;
|
||||
owner1FirstName: string;
|
||||
owner1LastName: string;
|
||||
owner2FirstName: string;
|
||||
owner2LastName: string;
|
||||
preForeclosure: boolean;
|
||||
cashBuyer: boolean;
|
||||
privateLender: boolean;
|
||||
lenderName: string;
|
||||
address: {
|
||||
zip: string;
|
||||
city: string;
|
||||
county: string;
|
||||
state: string;
|
||||
street: string;
|
||||
address: string;
|
||||
};
|
||||
mailAddress: {
|
||||
zip: string;
|
||||
city: string;
|
||||
county: string;
|
||||
state: string;
|
||||
street: string;
|
||||
address: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PropertyResponseDataSchoolAPI {
|
||||
city: string;
|
||||
enrollment: number;
|
||||
grades: string;
|
||||
location: string;
|
||||
name: string;
|
||||
parentRating: number;
|
||||
rating: number;
|
||||
address: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
type: 'Public' | 'other';
|
||||
}
|
||||
|
||||
export interface PropertResponseDataAPI {
|
||||
id: number;
|
||||
MFH2to4: boolean;
|
||||
MFH5plus: boolean;
|
||||
absenteeOwner: boolean;
|
||||
adjustableRate: boolean;
|
||||
assumable: boolean;
|
||||
auction: boolean;
|
||||
equity: number;
|
||||
bankOwned?: boolean;
|
||||
cashBuyer: boolean;
|
||||
cashSale: boolean;
|
||||
corporateOwned: boolean;
|
||||
death: boolean;
|
||||
deathTransfer: boolean;
|
||||
deedInLieu: boolean;
|
||||
equityPercent: number;
|
||||
estimatedEquity: number;
|
||||
estimatedMortgageBalance: string;
|
||||
estimatedMortgagePayment: string;
|
||||
estimatedValue: number;
|
||||
floodZone: boolean;
|
||||
floodZoneDescription: string;
|
||||
floodZoneType: string;
|
||||
freeClear: boolean;
|
||||
highEquity: boolean;
|
||||
inStateAbsenteeOwner: boolean;
|
||||
inherited: boolean;
|
||||
investorBuyer: boolean;
|
||||
judgment: boolean;
|
||||
lastSaleDate: string;
|
||||
lastSalePrice: string;
|
||||
lastUpdateDate: string;
|
||||
lien: boolean;
|
||||
loanTypeCodeFirst: string;
|
||||
loanTypeCodeSecond?: string;
|
||||
loanTypeCodeThird?: string;
|
||||
maturityDateFirst: string;
|
||||
mlsActive: boolean;
|
||||
mlsCancelled: boolean;
|
||||
mlsDaysOnMarket?: number;
|
||||
mlsFailed: boolean;
|
||||
mlsFailedDate?: string;
|
||||
mlsHasPhotos: boolean;
|
||||
mlsLastSaleDate?: string;
|
||||
mlsLastStatusDate?: string;
|
||||
mlsListingDate?: string;
|
||||
mlsListingPrice?: number;
|
||||
mlsListingPricePerSquareFoot?: number;
|
||||
mlsPending: boolean;
|
||||
mlsSold: boolean;
|
||||
mlsSoldPrice?: number;
|
||||
mlsStatus?: string;
|
||||
mlsTotalUpdates?: number;
|
||||
mlsType?: string;
|
||||
mobileHome: boolean;
|
||||
noticeType?: string;
|
||||
openMortgageBalance: number;
|
||||
outOfStateAbsenteeOwner: boolean;
|
||||
ownerOccupied: true;
|
||||
preForeclosure: boolean;
|
||||
privateLender: boolean;
|
||||
propertyType: string;
|
||||
quitClaim: boolean;
|
||||
reapi_loaded_at?: string;
|
||||
sheriffsDeed: boolean;
|
||||
spousalDeath: boolean;
|
||||
taxLien: boolean;
|
||||
trusteeSale: boolean;
|
||||
vacant: boolean;
|
||||
warrantyDeed: boolean;
|
||||
auctionInfo: {};
|
||||
currentMortgages: PropertyResponseDataMortgageAPI[];
|
||||
demographics: PropertyResponseDataDemographicsAPI;
|
||||
//foreclosureInfo:
|
||||
lastSale: PropertyResponseDataSaleAPI;
|
||||
//linkedProperties
|
||||
lotInfo: PropertyResponseDataLotInfoAPI;
|
||||
//mlsHistory
|
||||
//mlsKeywords
|
||||
mortgageHistory: PropertyResponseDataMortgageHistoryAPI[];
|
||||
//neighborhood
|
||||
ownerInfo: PropertyResponseDataOnwerInfoAPI;
|
||||
propertyInfo: PropertyResponseDataPropertyInfoAPI;
|
||||
saleHistory: PropertyResponseDataSaleAPI[];
|
||||
schools: PropertyResponseDataSchoolAPI[];
|
||||
taxInfo: PropertyResponseDataTaxInfoAPI;
|
||||
comps: PropertyResponseDataCompsAPI[];
|
||||
}
|
||||
|
||||
export interface PropertyResponseAPI {
|
||||
input: {
|
||||
comps: boolean;
|
||||
id: number;
|
||||
exact_match: boolean;
|
||||
};
|
||||
data: PropertResponseDataAPI;
|
||||
statusCode: number;
|
||||
statusMessage: string;
|
||||
live: boolean;
|
||||
requestExecutionTimeMS: string;
|
||||
propertyLookupExecutionTimeMS: string;
|
||||
compsLookupExecutionTimeMS: string;
|
||||
}
|
||||
|
||||
// Walk Score API Type Definitions
|
||||
26
ditch-the-agent/src/utils.tsx
Normal file
26
ditch-the-agent/src/utils.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export const formatTimestamp = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
return (
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +
|
||||
' ' +
|
||||
date.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
);
|
||||
};
|
||||
|
||||
export function extractLatLon(pointString: string): { latitude: number; longitude: number } {
|
||||
// Regular expression to match the numbers within the parentheses
|
||||
const regex = /POINT\(([-+]?\d+\.?\d*)\s+([-+]?\d+\.?\d*)\)/;
|
||||
const match = pointString.match(regex);
|
||||
|
||||
if (match && match.length === 3) {
|
||||
// The first captured group is the longitude, the second is the latitude
|
||||
const longitude = parseFloat(match[1]);
|
||||
const latitude = parseFloat(match[2]);
|
||||
|
||||
// Check if parsing was successful and resulted in valid numbers
|
||||
if (!isNaN(longitude) && !isNaN(latitude)) {
|
||||
return { latitude, longitude };
|
||||
}
|
||||
}
|
||||
return { latitude: 0, longitude: 0 }; // Return null if the string format is not as expected or parsing fails
|
||||
}
|
||||
@@ -15,5 +15,5 @@ export default defineConfig({
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
},
|
||||
base: '/elegent',
|
||||
base: '/',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user