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/material": "^5.15.14",
|
||||||
"@mui/x-data-grid": "^7.2.0",
|
"@mui/x-data-grid": "^7.2.0",
|
||||||
"@mui/x-data-grid-generator": "^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",
|
"axios": "^1.10.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"@typescript-eslint/parser": "^7.2.0",
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-prettier": "^5.5.3",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
|
|||||||
@@ -1,96 +1,105 @@
|
|||||||
import axios from "axios"
|
import axios from 'axios';
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
|
||||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
const baseURL = 'http://127.0.0.1:8010/api/';
|
||||||
//const baseURL = 'https://backend.ditchtheagent.com/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({
|
export const axiosInstance = axios.create({
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": 'JWT ' + localStorage.getItem('access_token'),
|
Authorization: 'JWT ' + localStorage.getItem('access_token'),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
Accept: 'application/json',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cleanAxiosInstance = axios.create({
|
export const cleanAxiosInstance = axios.create({
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
Accept: 'application/json',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const axiosInstanceCSRF = axios.create({
|
export const axiosInstanceCSRF = axios.create({
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
|
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
|
||||||
},
|
},
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
axiosInstance.interceptors.request.use(config => {
|
axiosInstance.interceptors.request.use((config) => {
|
||||||
config.timeout = 100000;
|
config.timeout = 100000;
|
||||||
return config;
|
return config;
|
||||||
})
|
});
|
||||||
|
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// Prevent infinite loop
|
// Prevent infinite loop
|
||||||
if (error.response.status === 401 && originalRequest.url === baseURL+'/token/refresh/') {
|
if (error.response.status === 401 && originalRequest.url === baseURL + '/token/refresh/') {
|
||||||
window.location.href = '/signin/';
|
window.location.href = '/authentication/login/';
|
||||||
//console.log('remove the local storage here')
|
//console.log('remove the local storage here')
|
||||||
return Promise.reject(error);
|
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);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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 { 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 {
|
interface FloatingActionButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -9,32 +30,26 @@ interface FloatingActionButtonProps {
|
|||||||
|
|
||||||
const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
|
const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
|
||||||
return (
|
return (
|
||||||
|
<Fab
|
||||||
<Fab
|
color="secondary"
|
||||||
color="secondary"
|
aria-label="open chat"
|
||||||
aria-label="open chat"
|
onClick={onClick}
|
||||||
onClick={onClick}
|
sx={{
|
||||||
sx={{
|
position: 'fixed',
|
||||||
position: 'fixed',
|
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
|
||||||
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
|
right: 24, // Equivalent to Tailwind's right-6 (24px)
|
||||||
right: 24, // Equivalent to Tailwind's right-6 (24px)
|
zIndex: 50, // Equivalent to Tailwind's z-50
|
||||||
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
|
||||||
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': {
|
||||||
'&:hover': {
|
backgroundColor: '#1d4ed8', // Blue-700 equivalent
|
||||||
backgroundColor: '#1d4ed8', // Blue-700 equivalent
|
},
|
||||||
},
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<MessageSquareText size={24} />
|
||||||
<MessageSquareText size={24} />
|
</Fab>
|
||||||
</Fab>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
text: string;
|
|
||||||
sender: 'user' | 'ai';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatPaneProps {
|
interface ChatPaneProps {
|
||||||
showChat: boolean;
|
showChat: boolean;
|
||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
@@ -42,7 +57,6 @@ interface ChatPaneProps {
|
|||||||
closeChat: () => void;
|
closeChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Chat Pane Component
|
// Chat Pane Component
|
||||||
const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPaneProps) => {
|
const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPaneProps) => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
@@ -52,8 +66,34 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
// Ref for the messages container to scroll to the bottom
|
// Ref for the messages container to scroll to the bottom
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messageRef = useRef('');
|
||||||
|
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||||
const theme = useTheme();
|
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
|
// Scroll to the bottom of the chat window whenever messages change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -62,56 +102,73 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
// Function to send a message to the AI
|
// Function to send a message to the AI
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (inputMessage.trim() === '') return;
|
if (inputMessage.trim() === '') return;
|
||||||
|
if (account !== undefined) {
|
||||||
const newUserMessage: ChatMessage = { text: inputMessage, sender: 'user' };
|
const newMessage: ChatMessage = {
|
||||||
setMessages((prevMessages: ChatMessage[]) => [...prevMessages, newUserMessage]);
|
text: inputMessage,
|
||||||
setInputMessage(''); // Clear input field
|
sender: 'user',
|
||||||
|
};
|
||||||
setIsLoading(true); // Show loading indicator
|
sendMessages([...messages, newMessage]);
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
setMessages((prevMessages) => [...prevMessages, newMessage]);
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
if (!showChat) return null; // Don't render anything if chat is not shown
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -127,7 +184,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
width: isMinimized ? '320px' : '384px', // w-80 (320px) vs w-96 (384px)
|
width: isMinimized ? '320px' : '384px', // w-80 (320px) vs w-96 (384px)
|
||||||
height: isMinimized ? '64px' : '485px', // h-16 (64px) vs h-[600px]
|
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)]
|
height: isMinimized ? '64px' : 'calc(100vh - 130px)', // md:h-[calc(100vh-80px)]
|
||||||
},
|
},
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
@@ -135,12 +193,16 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Chat Header */}
|
{/* Chat Header */}
|
||||||
<AppBar position="static" color='inherit' sx={{
|
<AppBar
|
||||||
backgroundColor: 'background.paper'
|
position="static"
|
||||||
}}>
|
color="inherit"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}> {/* minHeight to match h-16 for minimized */}
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}>
|
||||||
|
{' '}
|
||||||
|
{/* minHeight to match h-16 for minimized */}
|
||||||
<Typography variant="h6" component="div">
|
<Typography variant="h6" component="div">
|
||||||
AI Assistant
|
AI Assistant
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -148,15 +210,11 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={toggleMinimize}
|
onClick={toggleMinimize}
|
||||||
aria-label={isMinimized ? "Maximize chat" : "Minimize chat"}
|
aria-label={isMinimized ? 'Maximize chat' : 'Minimize chat'}
|
||||||
>
|
>
|
||||||
<Minus size={20} />
|
<Minus size={20} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton color="inherit" onClick={closeChat} aria-label="Close chat">
|
||||||
color="inherit"
|
|
||||||
onClick={closeChat}
|
|
||||||
aria-label="Close chat"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -200,7 +258,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
borderBottomLeftRadius: msg.sender === 'user' ? '12px' : 0,
|
borderBottomLeftRadius: msg.sender === 'user' ? '12px' : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2">{msg.text}</Typography>
|
{/*<Typography variant="body2">{msg.text}</Typography>*/}
|
||||||
|
<FormattedListingText text={msg.text} />
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -218,9 +277,19 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>.</Typography>
|
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>
|
||||||
<Typography component="span" className="animate-bounce delay-100" sx={{ mr: 0.5 }}>.</Typography>
|
.
|
||||||
<Typography component="span" className="animate-bounce delay-200">.</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
className="animate-bounce delay-100"
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
<Typography component="span" className="animate-bounce delay-200">
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -249,7 +318,9 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
size="small"
|
size="small"
|
||||||
value={inputMessage}
|
value={inputMessage}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputMessage(e.target.value)}
|
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..."
|
placeholder="Type your message..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -321,51 +392,46 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FloatingChatButton = (): ReactElement =>
|
const FloatingChatButton = (): ReactElement => {
|
||||||
{
|
const [showChat, setShowChat] = useState<boolean>(false);
|
||||||
const [showChat, setShowChat] = useState<boolean>(false);
|
// State to control if the chat pane is minimized
|
||||||
// State to control if the chat pane is minimized
|
const [isMinimized, setIsMinimized] = useState<boolean>(false);
|
||||||
const [isMinimized, setIsMinimized] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Function to toggle the chat pane visibility
|
// Function to toggle the chat pane visibility
|
||||||
const toggleChat = () => {
|
const toggleChat = () => {
|
||||||
setShowChat(!showChat);
|
setShowChat(!showChat);
|
||||||
// When opening, ensure it's not minimized
|
// When opening, ensure it's not minimized
|
||||||
if (!showChat) {
|
if (!showChat) {
|
||||||
setIsMinimized(false);
|
setIsMinimized(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to toggle minimize/maximize the chat pane
|
// Function to toggle minimize/maximize the chat pane
|
||||||
const toggleMinimize = () => {
|
const toggleMinimize = () => {
|
||||||
setIsMinimized(!isMinimized);
|
setIsMinimized(!isMinimized);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to close the chat pane
|
// Function to close the chat pane
|
||||||
const closeChat = () => {
|
const closeChat = () => {
|
||||||
setShowChat(false);
|
setShowChat(false);
|
||||||
setIsMinimized(false); // Reset minimize state when closing
|
setIsMinimized(false); // Reset minimize state when closing
|
||||||
};
|
};
|
||||||
return(
|
return (
|
||||||
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
|
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
|
||||||
{/* Floating Action Button */}
|
{/* Floating Action Button */}
|
||||||
{!showChat && <FloatingActionButton onClick={toggleChat} />}
|
{!showChat && <FloatingActionButton onClick={toggleChat} />}
|
||||||
|
|
||||||
<ChatPane
|
<ChatPane
|
||||||
showChat={showChat}
|
showChat={showChat}
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
toggleMinimize={toggleMinimize}
|
toggleMinimize={toggleMinimize}
|
||||||
closeChat={closeChat}
|
closeChat={closeChat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingChatButton;
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -107,16 +107,7 @@ const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
|
|||||||
category: "Pricing Strategy",
|
category: "Pricing Strategy",
|
||||||
progress: 100,
|
progress: 100,
|
||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
|
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 { 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 = {
|
type EducationInfoProps = {
|
||||||
title: string;
|
property: PropertiesAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProperyInfoCards = () => {
|
export const ProperyInfoCards = ({ property }: EducationInfoProps) => {
|
||||||
return(
|
return(
|
||||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
||||||
<PropertyInfo title={'1968 Greensboro Dr'} />
|
<PropertyInfo property={property} />
|
||||||
|
|
||||||
|
|
||||||
</Stack>
|
</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(
|
return(
|
||||||
<Card
|
<Card
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
@@ -25,7 +30,38 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
|||||||
height: 'auto',
|
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={{
|
sx={{
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -36,16 +72,16 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
|||||||
>
|
>
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
||||||
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
||||||
{title}
|
{property.address}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="column" justifyContent="space-between" flexWrap="wrap">
|
<Stack direction="column" justifyContent="space-between" flexWrap="wrap">
|
||||||
<Typography>
|
<Typography>
|
||||||
Estimated Home Value: <b>$700,500k</b>
|
Estimated Home Value: <b>${property.market_value}</b>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
Estimated Savings: $24,000k
|
Estimated Savings: ${estimated_savings}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
Compariable Time on market: 5 days
|
Compariable Time on market: 5 days
|
||||||
@@ -57,7 +93,7 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
</CardContent>
|
</CardContent> */}
|
||||||
|
|
||||||
|
|
||||||
</Card>
|
</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,111 +1,113 @@
|
|||||||
export interface NavItem {
|
import { NavItem } from 'types';
|
||||||
title: string;
|
|
||||||
path: string;
|
const navItems: NavItem[] = [
|
||||||
icon?: string;
|
{
|
||||||
active: boolean;
|
title: 'Home',
|
||||||
collapsible: boolean;
|
path: '/',
|
||||||
sublist?: NavItem[];
|
icon: 'ion:home-sharp',
|
||||||
}
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
const navItems: NavItem[] = [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Home',
|
title: 'Dashboard',
|
||||||
path: '/',
|
path: '/',
|
||||||
icon: 'ion:home-sharp',
|
active: false,
|
||||||
active: true,
|
collapsible: false,
|
||||||
collapsible: false,
|
},
|
||||||
sublist: [
|
],
|
||||||
{
|
},
|
||||||
title: 'Dashboard',
|
{
|
||||||
path: '/',
|
title: 'Profile',
|
||||||
active: false,
|
path: '/profile',
|
||||||
collapsible: false,
|
icon: 'ph:user-circle',
|
||||||
},
|
active: true,
|
||||||
{
|
collapsible: false,
|
||||||
title: 'Sales',
|
},
|
||||||
path: '/',
|
{
|
||||||
active: false,
|
title: 'Search',
|
||||||
collapsible: false,
|
path: '/property/search',
|
||||||
},
|
icon: 'ph:magnifying-glass',
|
||||||
],
|
active: true,
|
||||||
},
|
collapsible: false,
|
||||||
{
|
},
|
||||||
title: 'Authentication',
|
{
|
||||||
path: 'authentication',
|
title: 'Education',
|
||||||
icon: 'f7:exclamationmark-shield-fill',
|
path: '/education',
|
||||||
active: true,
|
icon: 'ph:student',
|
||||||
collapsible: true,
|
active: true,
|
||||||
sublist: [
|
collapsible: false,
|
||||||
{
|
},
|
||||||
title: 'Sign In',
|
{
|
||||||
path: 'login',
|
title: 'Vendors',
|
||||||
active: true,
|
path: '/vendors',
|
||||||
collapsible: false,
|
icon: 'ph:storefront',
|
||||||
},
|
active: true,
|
||||||
{
|
collapsible: false,
|
||||||
title: 'Sign Up',
|
},
|
||||||
path: 'sign-up',
|
{
|
||||||
active: true,
|
title: 'Messages',
|
||||||
collapsible: false,
|
path: '',
|
||||||
},
|
icon: 'ph:chat-circle-dots',
|
||||||
{
|
active: true,
|
||||||
title: 'Forgot password',
|
collapsible: true,
|
||||||
path: 'forgot-password',
|
sublist: [
|
||||||
active: true,
|
{
|
||||||
collapsible: false,
|
title: 'Offers',
|
||||||
},
|
path: 'offers',
|
||||||
{
|
icon: 'ph:certificate',
|
||||||
title: 'Reset password',
|
active: true,
|
||||||
path: 'reset-password',
|
collapsible: false,
|
||||||
active: true,
|
},
|
||||||
collapsible: false,
|
{
|
||||||
},
|
title: 'Conversations',
|
||||||
],
|
path: 'conversations',
|
||||||
},
|
icon: 'ph:chat-circle-dots',
|
||||||
{
|
active: true,
|
||||||
title: 'Notification',
|
collapsible: false,
|
||||||
path: '#!',
|
},
|
||||||
icon: 'zondicons:notifications',
|
|
||||||
active: false,
|
{
|
||||||
collapsible: false,
|
title: 'Bids',
|
||||||
},
|
path: 'bids',
|
||||||
{
|
icon: 'ph:chat-circle-dots',
|
||||||
title: 'Calendar',
|
active: true,
|
||||||
path: '#!',
|
collapsible: false,
|
||||||
icon: 'ph:calendar',
|
},
|
||||||
active: false,
|
],
|
||||||
collapsible: false,
|
},
|
||||||
},
|
{
|
||||||
{
|
title: 'Tools',
|
||||||
title: 'Message',
|
path: '/tools',
|
||||||
path: '#!',
|
icon: 'ph:toolbox',
|
||||||
icon: 'ph:chat-circle-dots-fill',
|
active: true,
|
||||||
active: false,
|
collapsible: true,
|
||||||
collapsible: false,
|
sublist: [
|
||||||
},
|
{
|
||||||
|
title: 'Mortgage Calculator',
|
||||||
{
|
path: 'mortgage-calculator',
|
||||||
title: 'Property',
|
active: true,
|
||||||
path: '/property',
|
collapsible: false,
|
||||||
icon: 'ph:house-line',
|
},
|
||||||
active: true,
|
{
|
||||||
collapsible: false,
|
title: 'Amortization Table',
|
||||||
},
|
path: 'amoritization-table',
|
||||||
{
|
active: true,
|
||||||
title: 'Education',
|
collapsible: false,
|
||||||
path: '/education',
|
},
|
||||||
icon: 'ph:student',
|
{
|
||||||
active: true,
|
title: 'Home Affordability',
|
||||||
collapsible: false,
|
path: 'home-affordability',
|
||||||
},
|
active: true,
|
||||||
{
|
collapsible: false,
|
||||||
title: 'Vendors',
|
},
|
||||||
path: '/vendors',
|
{
|
||||||
icon: 'ph:toolbox',
|
title: 'Net Terms Sheet',
|
||||||
active: true,
|
path: 'net-terms-sheet',
|
||||||
collapsible: false,
|
active: true,
|
||||||
},
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
],
|
||||||
|
},
|
||||||
export default navItems;
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
import { Link, Stack, Typography } from '@mui/material';
|
import { Link, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent={{ xs: 'center', md: 'flex-end' }}
|
justifyContent={{ xs: 'center' }}
|
||||||
ml={{ xs: 3.75, lg: 34.75 }}
|
ml={{ xs: 3.75, lg: 34.75 }}
|
||||||
mr={3.75}
|
mr={3.75}
|
||||||
my={3.75}
|
my={3.75}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
||||||
<Link
|
<Link
|
||||||
href="https://ditchtheagent.com/"
|
href="https://ditchtheagent.com/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
sx={{ color: 'text.primary', '&:hover': { color: 'primary.main' } }}
|
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
|
||||||
>
|
>
|
||||||
Ditch The Agent
|
Ditch The Agent
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footer;
|
export default Footer;
|
||||||
|
|||||||
@@ -1,109 +1,151 @@
|
|||||||
import { ReactElement } from 'react';
|
import { ReactElement, useContext } from 'react';
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Stack,
|
Stack,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
import logo from 'assets/logo/favicon-logo.png';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import Image from 'components/base/Image';
|
import Image from 'components/base/Image';
|
||||||
import navItems from 'data/nav-items';
|
|
||||||
import NavButton from './NavButton';
|
import NavButton from './NavButton';
|
||||||
|
import { axiosInstance } from '../../../axiosApi.js';
|
||||||
const Sidebar = (): ReactElement => {
|
import { useNavigate } from 'react-router-dom';
|
||||||
return (
|
import { AuthContext } from 'contexts/AuthContext';
|
||||||
<Stack
|
import { AccountContext } from 'contexts/AccountContext.js';
|
||||||
justifyContent="space-between"
|
|
||||||
bgcolor="background.paper"
|
import navItems from 'data/nav-items';
|
||||||
height={1}
|
import vendorNavItems from 'data/vendor-nav-items.js';
|
||||||
boxShadow={(theme) => theme.shadows[4]}
|
import basicNavItems from 'data/basic-nav-items.js';
|
||||||
sx={{
|
import { NavItem } from 'types.js';
|
||||||
overflow: 'hidden',
|
import attorneyNavItems from 'data/attorney-nav-items.js';
|
||||||
margin: { xs: 0, lg: 3.75 },
|
|
||||||
borderRadius: { xs: 0, lg: 5 },
|
const Sidebar = (): ReactElement => {
|
||||||
'&:hover': {
|
const navigate = useNavigate();
|
||||||
overflowY: 'auto',
|
const { authenticated, setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||||
},
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
width: 218,
|
|
||||||
}}
|
let nav_items: NavItem[] = [];
|
||||||
>
|
if (account && !accountLoading) {
|
||||||
<Link
|
if (account.user_type === 'property_owner') {
|
||||||
href="/"
|
if (account.tier === 'premium') {
|
||||||
sx={{
|
nav_items = navItems;
|
||||||
position: 'fixed',
|
} else {
|
||||||
zIndex: 5,
|
nav_items = basicNavItems;
|
||||||
mt: 6.25,
|
}
|
||||||
mx: 4.0625,
|
} else if (account.user_type === 'vendor') {
|
||||||
mb: 3.75,
|
nav_items = vendorNavItems;
|
||||||
bgcolor: 'background.paper',
|
} else if (account.user_type === 'attorney') {
|
||||||
borderRadius: 5,
|
nav_items = attorneyNavItems;
|
||||||
}}
|
}
|
||||||
>
|
}
|
||||||
<Image src={logo} width={1} />
|
|
||||||
</Link>
|
const handleSignOut = async () => {
|
||||||
<Stack
|
try {
|
||||||
justifyContent="space-between"
|
const response = await axiosInstance.post('/logout/', {
|
||||||
mt={16.25}
|
refresh_token: localStorage.getItem('refresh_token'),
|
||||||
height={1}
|
});
|
||||||
sx={{
|
localStorage.removeItem('access_token');
|
||||||
overflow: 'hidden',
|
localStorage.removeItem('refresh_token');
|
||||||
'&:hover': {
|
axiosInstance.defaults.headers['Authorization'] = null;
|
||||||
overflowY: 'auto',
|
setAuthentication(false);
|
||||||
},
|
} finally {
|
||||||
width: 218,
|
navigate('/authentication/login/');
|
||||||
}}
|
}
|
||||||
>
|
};
|
||||||
<List
|
return (
|
||||||
sx={{
|
<Stack
|
||||||
mx: 2.5,
|
justifyContent="space-between"
|
||||||
py: 1.25,
|
bgcolor="background.paper"
|
||||||
flex: '1 1 auto',
|
height={1}
|
||||||
width: 178,
|
boxShadow={(theme) => theme.shadows[4]}
|
||||||
}}
|
sx={{
|
||||||
>
|
overflow: 'hidden',
|
||||||
{navItems.map((navItem, index) => (
|
margin: { xs: 0, lg: 3.75 },
|
||||||
<NavButton key={index} navItem={navItem} Link={Link} />
|
borderRadius: { xs: 0, lg: 5 },
|
||||||
))}
|
'&:hover': {
|
||||||
</List>
|
overflowY: 'auto',
|
||||||
<List
|
},
|
||||||
sx={{
|
width: 218,
|
||||||
mx: 2.5,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Link
|
||||||
<ListItem
|
href="/"
|
||||||
sx={{
|
sx={{
|
||||||
mx: 0,
|
position: 'fixed',
|
||||||
my: 2.5,
|
zIndex: 5,
|
||||||
}}
|
mt: 6.25,
|
||||||
>
|
mx: 4.0625,
|
||||||
<ListItemButton
|
mb: 3.75,
|
||||||
LinkComponent={Link}
|
bgcolor: 'background.paper',
|
||||||
href="/"
|
borderRadius: 5,
|
||||||
sx={{
|
}}
|
||||||
backgroundColor: 'background.paper',
|
>
|
||||||
color: 'primary.main',
|
<Image src={logo} width={1} />
|
||||||
'&:hover': {
|
</Link>
|
||||||
backgroundColor: 'primary.main',
|
<Stack
|
||||||
color: 'common.white',
|
justifyContent="space-between"
|
||||||
opacity: 1.5,
|
mt={16.25}
|
||||||
},
|
height={1}
|
||||||
}}
|
sx={{
|
||||||
>
|
overflow: 'hidden',
|
||||||
<ListItemIcon>
|
'&:hover': {
|
||||||
<IconifyIcon icon="ri:logout-circle-line" />
|
overflowY: 'auto',
|
||||||
</ListItemIcon>
|
},
|
||||||
<ListItemText>Log out</ListItemText>
|
width: 218,
|
||||||
</ListItemButton>
|
}}
|
||||||
</ListItem>
|
>
|
||||||
</List>
|
<List
|
||||||
</Stack>
|
sx={{
|
||||||
</Stack>
|
mx: 2.5,
|
||||||
);
|
py: 1.25,
|
||||||
};
|
flex: '1 1 auto',
|
||||||
|
width: 178,
|
||||||
export default Sidebar;
|
}}
|
||||||
|
>
|
||||||
|
{nav_items.map((navItem, index) => (
|
||||||
|
<NavButton key={index} navItem={navItem} Link={Link} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<List
|
||||||
|
sx={{
|
||||||
|
mx: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
sx={{
|
||||||
|
mx: 0,
|
||||||
|
my: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
LinkComponent={Link}
|
||||||
|
href="/"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'background.default',
|
||||||
|
color: 'common.white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'common.white',
|
||||||
|
opacity: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<IconifyIcon icon="ri:logout-circle-line" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText onClick={handleSignOut}>Log out</ListItemText>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
|||||||
@@ -86,28 +86,6 @@ const Topbar = ({ handleDrawerToggle }: TopbarProps): ReactElement => {
|
|||||||
<Typography variant="h5" component="h5">
|
<Typography variant="h5" component="h5">
|
||||||
{pathname === '/' ? 'Dashboard' : title}
|
{pathname === '/' ? 'Dashboard' : title}
|
||||||
</Typography>
|
</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>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
@@ -1,93 +1,91 @@
|
|||||||
import { PropsWithChildren, ReactElement, useState } from 'react';
|
import { PropsWithChildren, ReactElement, useState } from 'react';
|
||||||
import { Box, Drawer, Stack, Toolbar } from '@mui/material';
|
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 Sidebar from 'layouts/main-layout/Sidebar/Sidebar';
|
import Footer from './Footer';
|
||||||
import Topbar from 'layouts/main-layout/Topbar/Topbar';
|
import FloatingChatButton from 'components/FloatingChatButton';
|
||||||
import Footer from './Footer';
|
|
||||||
import FloatingChatButton from 'components/FloatingChatButton';
|
export const drawerWidth = 278;
|
||||||
|
|
||||||
export const drawerWidth = 278;
|
const MainLayout = ({ children }: PropsWithChildren): ReactElement => {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const MainLayout = ({ children }: PropsWithChildren): ReactElement => {
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const handleDrawerClose = () => {
|
||||||
|
setIsClosing(true);
|
||||||
const handleDrawerClose = () => {
|
setMobileOpen(false);
|
||||||
setIsClosing(true);
|
};
|
||||||
setMobileOpen(false);
|
|
||||||
};
|
const handleDrawerTransitionEnd = () => {
|
||||||
|
setIsClosing(false);
|
||||||
const handleDrawerTransitionEnd = () => {
|
};
|
||||||
setIsClosing(false);
|
|
||||||
};
|
const handleDrawerToggle = () => {
|
||||||
|
if (!isClosing) {
|
||||||
const handleDrawerToggle = () => {
|
setMobileOpen(!mobileOpen);
|
||||||
if (!isClosing) {
|
}
|
||||||
setMobileOpen(!mobileOpen);
|
};
|
||||||
}
|
|
||||||
};
|
return (
|
||||||
|
<>
|
||||||
return (
|
<Stack direction="row" minHeight="100vh" bgcolor="background.default">
|
||||||
<>
|
<Topbar handleDrawerToggle={handleDrawerToggle} />
|
||||||
<Stack direction="row" minHeight="100vh" bgcolor="background.default">
|
<Box
|
||||||
<Topbar handleDrawerToggle={handleDrawerToggle} />
|
component="nav"
|
||||||
<Box
|
sx={{ width: { lg: drawerWidth }, flexShrink: { lg: 0 } }}
|
||||||
component="nav"
|
aria-label="mailbox folders"
|
||||||
sx={{ width: { lg: drawerWidth }, flexShrink: { lg: 0 } }}
|
>
|
||||||
aria-label="mailbox folders"
|
<Drawer
|
||||||
>
|
variant="temporary"
|
||||||
<Drawer
|
open={mobileOpen}
|
||||||
variant="temporary"
|
onTransitionEnd={handleDrawerTransitionEnd}
|
||||||
open={mobileOpen}
|
onClose={handleDrawerClose}
|
||||||
onTransitionEnd={handleDrawerTransitionEnd}
|
ModalProps={{
|
||||||
onClose={handleDrawerClose}
|
keepMounted: true, // Better open performance on mobile.
|
||||||
ModalProps={{
|
}}
|
||||||
keepMounted: true, // Better open performance on mobile.
|
sx={{
|
||||||
}}
|
display: { xs: 'block', lg: 'none' },
|
||||||
sx={{
|
'& .MuiDrawer-paper': {
|
||||||
display: { xs: 'block', lg: 'none' },
|
boxSizing: 'border-box',
|
||||||
'& .MuiDrawer-paper': {
|
border: 0,
|
||||||
boxSizing: 'border-box',
|
backgroundColor: 'background.default',
|
||||||
border: 0,
|
},
|
||||||
backgroundColor: 'background.default',
|
}}
|
||||||
},
|
>
|
||||||
}}
|
<Sidebar />
|
||||||
>
|
</Drawer>
|
||||||
<Sidebar />
|
<Drawer
|
||||||
</Drawer>
|
variant="permanent"
|
||||||
<Drawer
|
sx={{
|
||||||
variant="permanent"
|
display: { xs: 'none', lg: 'block' },
|
||||||
sx={{
|
'& .MuiDrawer-paper': {
|
||||||
display: { xs: 'none', lg: 'block' },
|
boxSizing: 'border-box',
|
||||||
'& .MuiDrawer-paper': {
|
width: drawerWidth,
|
||||||
boxSizing: 'border-box',
|
border: 0,
|
||||||
width: drawerWidth,
|
backgroundColor: 'background.default',
|
||||||
border: 0,
|
},
|
||||||
backgroundColor: 'background.default',
|
}}
|
||||||
},
|
open
|
||||||
}}
|
>
|
||||||
open
|
<Sidebar />
|
||||||
>
|
</Drawer>
|
||||||
<Sidebar />
|
</Box>
|
||||||
</Drawer>
|
<Toolbar
|
||||||
</Box>
|
sx={{
|
||||||
<Toolbar
|
pt: 12,
|
||||||
sx={{
|
width: 1,
|
||||||
pt: 12,
|
pb: 0,
|
||||||
width: 1,
|
alignItems: 'start',
|
||||||
pb: 0,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{children}
|
||||||
|
<FloatingChatButton />
|
||||||
{children}
|
</Toolbar>
|
||||||
<FloatingChatButton />
|
</Stack>
|
||||||
</Toolbar>
|
<Footer />
|
||||||
</Stack>
|
</>
|
||||||
<Footer />
|
);
|
||||||
</>
|
};
|
||||||
);
|
|
||||||
};
|
export default MainLayout;
|
||||||
|
|
||||||
export default MainLayout;
|
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { theme } from './theme/theme.ts';
|
import { theme } from './theme/theme.ts';
|
||||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||||
import BreakpointsProvider from 'providers/BreakpointsProvider.tsx';
|
import BreakpointsProvider from 'providers/BreakpointsProvider.tsx';
|
||||||
import router from 'routes/router.tsx';
|
import router from 'routes/router.tsx';
|
||||||
import { AuthProvider } from 'contexts/AuthContext.tsx';
|
import { AuthProvider } from 'contexts/AuthContext.tsx';
|
||||||
|
import { AccountProvider } from 'contexts/AccountContext.tsx';
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
import { WebSocketProvider } from 'contexts/WebSocketContext.tsx';
|
||||||
<React.StrictMode>
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<BreakpointsProvider>
|
||||||
<BreakpointsProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<AccountProvider>
|
||||||
|
<WebSocketProvider>
|
||||||
|
<CssBaseline />
|
||||||
<CssBaseline />
|
<RouterProvider router={router} />
|
||||||
|
</WebSocketProvider>
|
||||||
<RouterProvider router={router} />
|
</AccountProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BreakpointsProvider>
|
</BreakpointsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
</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 { ReactElement, useEffect, useState } from 'react';
|
||||||
import { drawerWidth } from 'layouts/main-layout';
|
import {axiosInstance} from '../../axiosApi'
|
||||||
import Grid from '@mui/material/Unstable_Grid2';
|
import DashboardTemplate from 'components/DasboardTemplate';
|
||||||
import { EducationInfoCards } from 'components/sections/dashboard/Home/Education/EducationInfo';
|
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 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(
|
return(
|
||||||
<Grid
|
<DashboardTemplate<VideoCategory, VideoItem>
|
||||||
container
|
pageTitle="Educational Videos"
|
||||||
component="main"
|
data={{ categories: videoCategories, items: allVideos }}
|
||||||
columns={12}
|
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||||
spacing={3.75}
|
<CategoryGridTemplate
|
||||||
flexGrow={1}
|
categories={categories}
|
||||||
pt={4.375}
|
onSelectCategory={(id) => onSelectCategory(id)}
|
||||||
pr={1.875}
|
renderCategoryCard={(category, onSelect) => (
|
||||||
pb={0}
|
<VideoCategoryCard category={category as VideoCategory} onSelectCategory={onSelect} />
|
||||||
sx={{
|
)}
|
||||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
/>
|
||||||
pl: { xs: 3.75, lg: 0 },
|
)}
|
||||||
}}
|
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||||
>
|
<ItemListDetailTemplate
|
||||||
<Grid xs={12} md={12}>
|
category={selectedCategory}
|
||||||
<EducationInfoCards />
|
items={itemsInSelectedCategory}
|
||||||
</Grid>
|
onBack={onBack}
|
||||||
</Grid>
|
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;
|
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 React, { useState } from 'react';
|
||||||
import { drawerWidth } from 'layouts/main-layout';
|
import { Container, Typography, Box, Grid, Alert } from '@mui/material';
|
||||||
import Grid from '@mui/material/Unstable_Grid2';
|
import { PropertiesAPI } from 'types';
|
||||||
import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
|
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||||
import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
|
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||||
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';
|
|
||||||
|
|
||||||
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>
|
// Reusing the mockProperties from PropertyDetailPage for consistent data
|
||||||
<Grid xs={12} md={4}>
|
const mockProperties: PropertiesAPI[] = [
|
||||||
<HomePriceEstimate />
|
{
|
||||||
|
id: 101,
|
||||||
</Grid>
|
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' },
|
||||||
<Grid xs={12} md={8}>
|
address: '123 Main St',
|
||||||
<PhotoGalleryCard />
|
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={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;
|
const Property: React.FC = () => {
|
||||||
|
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
const filterProperties = (filters: any) => {
|
||||||
|
const filtered = mockProperties.filter(property => {
|
||||||
|
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
|
||||||
|
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
|
||||||
|
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
|
||||||
|
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
||||||
|
|
||||||
|
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
||||||
|
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
||||||
|
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
||||||
|
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
||||||
|
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
||||||
|
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
||||||
|
|
||||||
|
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
|
||||||
|
sqFtMatch && bedroomsMatch && bathroomsMatch;
|
||||||
|
});
|
||||||
|
setSearchResults(filtered);
|
||||||
|
setInitialLoad(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (filters: any) => {
|
||||||
|
filterProperties(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchResults([]); // Clear results on clear
|
||||||
|
setInitialLoad(true); // Reset to initial state
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Property Search
|
||||||
|
</Typography>
|
||||||
|
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||||
|
|
||||||
|
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
||||||
|
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{searchResults.length === 0 && !initialLoad ? (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
searchResults.map(property => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={property.id}>
|
||||||
|
<PropertyListItem
|
||||||
|
property={property}
|
||||||
|
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Property;
|
||||||
|
|
||||||
|
// import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
|
// import { drawerWidth } from 'layouts/main-layout';
|
||||||
|
// import PropertyDetailsCard from 'components/sections/dashboard/Home/Property/PropertyDetailsCard';
|
||||||
|
// import HomePriceEstimate from 'components/sections/dashboard/Home/Property/HomePriceEstimate';
|
||||||
|
// import PhotoGalleryCard from 'components/sections/dashboard/Home/Property/PhotoGalleryCard';
|
||||||
|
// import MarketStatistics from 'components/sections/dashboard/Home/Property/MarketStatistics';
|
||||||
|
// import PropertyListingCard from 'components/sections/dashboard/Home/Property/PropertyListingCard';
|
||||||
|
// import LoanDetailsCard from 'components/sections/dashboard/Home/Property/LoanDetailsCard';
|
||||||
|
// import PropertyValueGraphCard from 'components/sections/dashboard/Home/Property/PropertyValueGraphCard';
|
||||||
|
// import {axiosInstance} from '../../axiosApi'
|
||||||
|
// import { PropertiesAPI } from 'types';
|
||||||
|
// import { AxiosResponse } from 'axios';
|
||||||
|
// import LoadingSkeleton from 'components/base/LoadingSkeleton';
|
||||||
|
// import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
// import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack } from '@mui/material';
|
||||||
|
// import HouseIcon from '@mui/icons-material/House';
|
||||||
|
// import { formatTimestamp } from 'utils';
|
||||||
|
|
||||||
|
|
||||||
|
// const Property = (): ReactElement => {
|
||||||
|
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
|
||||||
|
// const {account, accountLoading} = useContext(AccountContext);
|
||||||
|
// if(accountLoading){
|
||||||
|
// return <LoadingSkeleton />
|
||||||
|
// }
|
||||||
|
// useEffect(() => {
|
||||||
|
// const fetchProperties = async() => {
|
||||||
|
// try{
|
||||||
|
// const {data, }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/')
|
||||||
|
// if(data.length > 0){
|
||||||
|
// setProperties(data);
|
||||||
|
// setSelectedPropertyId(data[0].id)
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// }catch(error){
|
||||||
|
// console.log(error)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// fetchProperties();
|
||||||
|
// }, [])
|
||||||
|
|
||||||
|
// const selectedProperty = properties.find(
|
||||||
|
// (property) => property.id === selectedPropertyId
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return(
|
||||||
|
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
// <Grid container sx={{ minHeight: '100%' }}>
|
||||||
|
// {/* Left Panel: Conversation List */}
|
||||||
|
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||||
|
// <Stack direction="row" sx={{width:'100%'}}>
|
||||||
|
|
||||||
|
|
||||||
|
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
// Properties
|
||||||
|
// </Typography>
|
||||||
|
// </Stack>
|
||||||
|
// </Box>
|
||||||
|
// <List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||||
|
// {properties.length === 0 ? (
|
||||||
|
// <Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||||
|
// <HouseIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||||
|
// <Typography>No properties yet. Go to profile to add one.</Typography>
|
||||||
|
// </Box>
|
||||||
|
// ) : (
|
||||||
|
|
||||||
|
// properties
|
||||||
|
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
// .map((conv) => (
|
||||||
|
// <ListItem
|
||||||
|
// key={conv.id}
|
||||||
|
// button
|
||||||
|
// selected={selectedPropertyId === conv.id}
|
||||||
|
// onClick={() => setSelectedPropertyId(conv.id)}
|
||||||
|
// sx={{ py: 1.5, px: 2 }}
|
||||||
|
// >
|
||||||
|
// <ListItemText
|
||||||
|
// primary={
|
||||||
|
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
// {conv.address}
|
||||||
|
// </Typography>
|
||||||
|
// }
|
||||||
|
// secondary={
|
||||||
|
// <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
// <Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
|
||||||
|
// {conv.market_value}
|
||||||
|
// </Typography>
|
||||||
|
// <Typography variant="caption" color="text.disabled">
|
||||||
|
// {formatTimestamp(conv.last_updated)}
|
||||||
|
// </Typography>
|
||||||
|
// </Box>
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
// </ListItem>
|
||||||
|
// ))
|
||||||
|
// )}
|
||||||
|
// </List>
|
||||||
|
// </Grid>
|
||||||
|
|
||||||
|
// {/* Right Panel: Conversation Detail */}
|
||||||
|
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
// {selectedProperty ? (
|
||||||
|
// <>
|
||||||
|
// {/* Conversation Header */}
|
||||||
|
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
// <Avatar sx={{ bgcolor: 'purple.200'}}>
|
||||||
|
// {/* {selectedProperty.withName.split(' ').map(n => n[0]).join('')} */}
|
||||||
|
// </Avatar>
|
||||||
|
// <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||||
|
// {selectedProperty.address}
|
||||||
|
// </Typography>
|
||||||
|
// </Box>
|
||||||
|
|
||||||
|
// {/* Messages Area */}
|
||||||
|
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||||
|
// <Grid
|
||||||
|
// container
|
||||||
|
// component="main"
|
||||||
|
// columns={12}
|
||||||
|
// spacing={3.75}
|
||||||
|
// flexGrow={1}
|
||||||
|
// pt={4.375}
|
||||||
|
// pr={1.875}
|
||||||
|
// pb={0}
|
||||||
|
// sx={{
|
||||||
|
// width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
// pl: { xs: 3.75, lg: 0 },
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <Grid xs={12} md={8}>
|
||||||
|
|
||||||
|
// <PropertyDetailsCard />
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={4}>
|
||||||
|
// <HomePriceEstimate />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={8}>
|
||||||
|
// <PhotoGalleryCard />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={4}>
|
||||||
|
// <MarketStatistics />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={8}>
|
||||||
|
// <PropertyValueGraphCard />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={4}>
|
||||||
|
// <LoanDetailsCard />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// <Grid xs={12} md={4}>
|
||||||
|
// <PropertyListingCard />
|
||||||
|
|
||||||
|
// </Grid>
|
||||||
|
// </Grid>
|
||||||
|
|
||||||
|
// </Box>
|
||||||
|
|
||||||
|
|
||||||
|
// </>
|
||||||
|
// ) : (
|
||||||
|
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||||
|
// <HouseIcon sx={{ fontSize: 80, mb: 2 }} />
|
||||||
|
// <Typography variant="h6">Select a property to manage</Typography>
|
||||||
|
// <Typography variant="body2">Click on a property from the left panel to get started.</Typography>
|
||||||
|
// </Box>
|
||||||
|
// )}
|
||||||
|
// </Grid>
|
||||||
|
// </Grid>
|
||||||
|
// </Paper>
|
||||||
|
// </Container>
|
||||||
|
// )
|
||||||
|
|
||||||
|
// // return(
|
||||||
|
// // <Grid
|
||||||
|
// // container
|
||||||
|
// // component="main"
|
||||||
|
// // columns={12}
|
||||||
|
// // spacing={3.75}
|
||||||
|
// // flexGrow={1}
|
||||||
|
// // pt={4.375}
|
||||||
|
// // pr={1.875}
|
||||||
|
// // pb={0}
|
||||||
|
// // sx={{
|
||||||
|
// // width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
// // pl: { xs: 3.75, lg: 0 },
|
||||||
|
// // }}
|
||||||
|
// // >
|
||||||
|
// // <Grid xs={12} md={8}>
|
||||||
|
|
||||||
|
// // <PropertyDetailsCard />
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={4}>
|
||||||
|
// // <HomePriceEstimate />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={8}>
|
||||||
|
// // <PhotoGalleryCard />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={4}>
|
||||||
|
// // <MarketStatistics />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={8}>
|
||||||
|
// // <PropertyValueGraphCard />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={4}>
|
||||||
|
// // <LoanDetailsCard />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // <Grid xs={12} md={4}>
|
||||||
|
// // <PropertyListingCard />
|
||||||
|
|
||||||
|
// // </Grid>
|
||||||
|
// // </Grid>
|
||||||
|
// // )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default Property;
|
||||||
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 { 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 => {
|
const Vendors = (): ReactElement => {
|
||||||
return(
|
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
|
||||||
<Grid
|
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Vendors;
|
// 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 {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -15,12 +16,55 @@ import loginBanner from 'assets/authentication-banners/green.png';
|
|||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
import logo from 'assets/logo/favicon-logo.png';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import Image from 'components/base/Image';
|
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 Login = (): ReactElement => {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
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 (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@@ -35,67 +79,104 @@ const Login = (): ReactElement => {
|
|||||||
</Link>
|
</Link>
|
||||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
||||||
<Typography variant="h3">Login</Typography>
|
<Typography variant="h3">Login</Typography>
|
||||||
<FormControl variant="standard" fullWidth>
|
<Formik
|
||||||
<InputLabel shrink htmlFor="email">
|
initialValues={{
|
||||||
Email
|
email: '',
|
||||||
</InputLabel>
|
password: '',
|
||||||
<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',
|
|
||||||
}}
|
}}
|
||||||
>
|
onSubmit={handleLogin}
|
||||||
<Link href="/authentication/forgot-password" underline="hover">
|
>
|
||||||
Forget password
|
{({setFieldValue}) => (
|
||||||
</Link>
|
|
||||||
</Typography>
|
<Form>
|
||||||
<Button variant="contained" fullWidth>
|
<FormControl variant="standard" fullWidth>
|
||||||
Log in
|
{errorMessage ? (
|
||||||
</Button>
|
<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">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Don't have an account ?{' '}
|
Don't have an account ?{' '}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,270 +1,314 @@
|
|||||||
import { ReactElement, Suspense, useState } from 'react';
|
import { ReactElement, Suspense, useState } from 'react';
|
||||||
import { Form, Formik } from 'formik';
|
import { ErrorMessage, Form, Formik } from 'formik';
|
||||||
import {
|
import {
|
||||||
Button,
|
Alert,
|
||||||
FormControl,
|
Button,
|
||||||
FormControlLabel,
|
FormControl,
|
||||||
IconButton,
|
FormControlLabel,
|
||||||
InputAdornment,
|
IconButton,
|
||||||
InputLabel,
|
InputAdornment,
|
||||||
Link,
|
InputLabel,
|
||||||
OutlinedInput,
|
Link,
|
||||||
Radio,
|
OutlinedInput,
|
||||||
RadioGroup,
|
Radio,
|
||||||
Skeleton,
|
RadioGroup,
|
||||||
Stack,
|
Skeleton,
|
||||||
TextField,
|
Stack,
|
||||||
Typography,
|
TextField,
|
||||||
} from '@mui/material';
|
Typography,
|
||||||
import signupBanner from 'assets/authentication-banners/green.png';
|
} from '@mui/material';
|
||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import signupBanner from 'assets/authentication-banners/green.png';
|
||||||
import logo from 'assets/logo/favicon-logo.png';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
import Image from 'components/base/Image';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import{axiosInstance} from '../../axiosApi.js';
|
import Image from 'components/base/Image';
|
||||||
|
import { axiosInstance } from '../../axiosApi.js';
|
||||||
type SignUpValues = {
|
import { useNavigate } from 'react-router-dom';
|
||||||
email: string;
|
|
||||||
first_name: string;
|
type SignUpValues = {
|
||||||
last_name: string;
|
email: string;
|
||||||
password: string;
|
first_name: string;
|
||||||
password2: string;
|
last_name: string;
|
||||||
ownerType: string;
|
password: string;
|
||||||
}
|
password2: string;
|
||||||
|
ownerType: string;
|
||||||
const SignUp = (): ReactElement => {
|
};
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showPassword2, setShowPassword2] = useState(false);
|
const SignUp = (): ReactElement => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
// const [firstName, setFirstName] = useState('');
|
const [showPassword2, setShowPassword2] = useState(false);
|
||||||
// const [lastName, setLastName] = useState('');
|
|
||||||
// const [email, setEmail] = useState('');
|
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||||
// const [password, setPassword] = useState('');
|
|
||||||
// const [password2, setPassword2] = useState('');
|
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
||||||
// const [ownerType, setOwnerType] = useState('');
|
const handleClickShowPassword2 = () => setShowPassword2(!showPassword2);
|
||||||
|
const navigate = useNavigate();
|
||||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
|
||||||
const handleClickShowPassword2 = () => setShowPassword2(!showPassword2);
|
const handleSignUp = async ({
|
||||||
|
email,
|
||||||
const handleSignUp = async({email, first_name, last_name, ownerType, password, password2}: SignUpValues): Promise<void> => {
|
first_name,
|
||||||
|
last_name,
|
||||||
console.log({
|
ownerType,
|
||||||
email: email,
|
password,
|
||||||
first_name: first_name,
|
password2,
|
||||||
last_name: last_name,
|
}: SignUpValues): Promise<void> => {
|
||||||
user_type: ownerType,
|
try {
|
||||||
password: password,
|
const response = await axiosInstance.post('/register/', {
|
||||||
password2: password2
|
email: email,
|
||||||
})
|
first_name: first_name,
|
||||||
const response = await axiosInstance.post('/api/register',
|
last_name: last_name,
|
||||||
{
|
user_type: ownerType,
|
||||||
email: email,
|
password: password,
|
||||||
first_name: first_name,
|
password2: password2,
|
||||||
last_name: last_name,
|
});
|
||||||
user_type: ownerType,
|
if (response.status == 201) {
|
||||||
password: password,
|
navigate('/authentication/login');
|
||||||
password2: password2
|
} else {
|
||||||
}
|
console.log(`No good: ${response}`);
|
||||||
)
|
}
|
||||||
}
|
} catch (error) {
|
||||||
return (
|
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||||
<Stack
|
if (hasErrors) {
|
||||||
direction="row"
|
setErrorMessage(error.response.data);
|
||||||
bgcolor="background.paper"
|
} else {
|
||||||
boxShadow={(theme) => theme.shadows[3]}
|
setErrorMessage(null);
|
||||||
|
}
|
||||||
width={{ md: 960 }}
|
}
|
||||||
>
|
};
|
||||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
return (
|
||||||
<Link href="/" width="fit-content">
|
<Stack
|
||||||
<Image src={logo} width={82.6} />
|
direction="row"
|
||||||
</Link>
|
bgcolor="background.paper"
|
||||||
<Stack alignItems="center" gap={2.5} mx="auto">
|
boxShadow={(theme) => theme.shadows[3]}
|
||||||
<Typography variant="h3">Signup</Typography>
|
width={{ md: 960 }}
|
||||||
<Formik
|
>
|
||||||
initialValues={{
|
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||||
first_name: '',
|
<Link href="/" width="fit-content">
|
||||||
email: '',
|
<Image src={logo} width={82.6} />
|
||||||
last_name: '',
|
</Link>
|
||||||
password: '',
|
<Stack alignItems="center" gap={2.5} mx="auto">
|
||||||
password2: '',
|
<Typography variant="h3">Signup</Typography>
|
||||||
ownerType: "property_owner"
|
<Formik
|
||||||
}}
|
initialValues={{
|
||||||
onSubmit={handleSignUp}
|
first_name: '',
|
||||||
|
email: '',
|
||||||
>
|
last_name: '',
|
||||||
{(formik) => (
|
password: '',
|
||||||
<Form>
|
password2: '',
|
||||||
<FormControl variant="standard" fullWidth>
|
ownerType: 'property_owner',
|
||||||
<InputLabel shrink htmlFor="name">
|
}}
|
||||||
First Name
|
onSubmit={handleSignUp}
|
||||||
</InputLabel>
|
>
|
||||||
<TextField
|
{({ setFieldValue }) => (
|
||||||
variant="filled"
|
<Form>
|
||||||
onChange={(event) => formik.setFieldValue('first_name', event.target.value)}
|
<FormControl variant="standard" fullWidth>
|
||||||
placeholder="Enter your first name"
|
{errorMessage ? (
|
||||||
id="first_name"
|
<Alert severity="error">
|
||||||
InputProps={{
|
<ul>
|
||||||
endAdornment: (
|
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
<li key={fieldName}>
|
||||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
<strong>{fieldName}</strong>
|
||||||
</InputAdornment>
|
{errorMessages.length > 0 ? (
|
||||||
),
|
<ul>
|
||||||
}}
|
{errorMessages.map((message, index) => (
|
||||||
/>
|
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
|
||||||
</FormControl>
|
))}
|
||||||
<FormControl variant="standard" fullWidth>
|
</ul>
|
||||||
<InputLabel shrink htmlFor="name">
|
) : (
|
||||||
Last Name
|
<span> No specific errors for this field.</span>
|
||||||
</InputLabel>
|
)}
|
||||||
<TextField
|
</li>
|
||||||
variant="filled"
|
))}
|
||||||
onChange={(event) => formik.setFieldValue('last_name', event.target.value)}
|
</ul>
|
||||||
placeholder="Enter your last name"
|
</Alert>
|
||||||
id="last_name"
|
) : null}
|
||||||
InputProps={{
|
<InputLabel shrink htmlFor="name">
|
||||||
endAdornment: (
|
First Name
|
||||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
</InputLabel>
|
||||||
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
<TextField
|
||||||
</InputAdornment>
|
variant="filled"
|
||||||
),
|
onChange={(event) => setFieldValue('first_name', event.target.value)}
|
||||||
}}
|
placeholder="Enter your first name"
|
||||||
/>
|
id="first_name"
|
||||||
</FormControl>
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
<FormControl variant="standard" fullWidth>
|
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||||
<InputLabel shrink htmlFor="email">
|
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||||
Email
|
</InputAdornment>
|
||||||
</InputLabel>
|
),
|
||||||
<OutlinedInput
|
}}
|
||||||
placeholder="Enter your email"
|
/>
|
||||||
id="email"
|
</FormControl>
|
||||||
endAdornment={
|
<FormControl variant="standard" fullWidth>
|
||||||
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
<InputLabel shrink htmlFor="name">
|
||||||
<IconifyIcon icon="ic:baseline-email" width={1} height={1} />
|
Last Name
|
||||||
</InputAdornment>
|
</InputLabel>
|
||||||
}
|
<TextField
|
||||||
sx={{
|
variant="filled"
|
||||||
width: 1,
|
onChange={(event) => setFieldValue('last_name', event.target.value)}
|
||||||
backgroundColor: 'action.focus',
|
placeholder="Enter your last name"
|
||||||
}}
|
id="last_name"
|
||||||
onChange={(event) => formik.setFieldValue('email', event.target.value)}
|
InputProps={{
|
||||||
/>
|
endAdornment: (
|
||||||
</FormControl>
|
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||||
<FormControl variant="standard" fullWidth>
|
<IconifyIcon icon="mdi:user" width={1} height={1} />
|
||||||
<InputLabel shrink htmlFor="email">
|
</InputAdornment>
|
||||||
Account Type
|
),
|
||||||
</InputLabel>
|
}}
|
||||||
<RadioGroup
|
/>
|
||||||
row
|
</FormControl>
|
||||||
onChange={(event) => formik.setFieldValue('ownerType', event.target.value)}
|
|
||||||
>
|
<FormControl variant="standard" fullWidth>
|
||||||
<FormControlLabel value="property_owner" control={<Radio />} name="ownerType" label="Owner"/>
|
<InputLabel shrink htmlFor="email">
|
||||||
<FormControlLabel value="vendor" control={<Radio />} name="ownerType" label="Vendor" />
|
Email
|
||||||
|
</InputLabel>
|
||||||
</RadioGroup>
|
<OutlinedInput
|
||||||
|
placeholder="Enter your email"
|
||||||
</FormControl>
|
id="email"
|
||||||
<FormControl variant="standard" fullWidth>
|
endAdornment={
|
||||||
<InputLabel shrink htmlFor="password">
|
<InputAdornment position="end" sx={{ width: 16, height: 16 }}>
|
||||||
Password
|
<IconifyIcon icon="ic:baseline-email" width={1} height={1} />
|
||||||
</InputLabel>
|
</InputAdornment>
|
||||||
<TextField
|
}
|
||||||
variant="filled"
|
sx={{
|
||||||
placeholder="********"
|
width: 1,
|
||||||
onChange={(event) => formik.setFieldValue('password', event.target.value)}
|
backgroundColor: 'action.focus',
|
||||||
type={showPassword ? 'text' : 'password'}
|
}}
|
||||||
id="password"
|
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||||
InputProps={{
|
/>
|
||||||
endAdornment: (
|
</FormControl>
|
||||||
<InputAdornment position="end">
|
<FormControl variant="standard" fullWidth>
|
||||||
<IconButton
|
<InputLabel shrink htmlFor="email">
|
||||||
aria-label="toggle password visibility"
|
Account Type
|
||||||
onClick={handleClickShowPassword}
|
</InputLabel>
|
||||||
edge="end"
|
<RadioGroup
|
||||||
sx={{
|
row
|
||||||
color: 'text.secondary',
|
onChange={(event) => setFieldValue('ownerType', event.target.value)}
|
||||||
}}
|
>
|
||||||
>
|
<FormControlLabel
|
||||||
{showPassword ? (
|
value="property_owner"
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
control={<Radio />}
|
||||||
) : (
|
name="ownerType"
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
label="Owner"
|
||||||
)}
|
/>
|
||||||
</IconButton>
|
<FormControlLabel
|
||||||
</InputAdornment>
|
value="vendor"
|
||||||
),
|
control={<Radio />}
|
||||||
}}
|
name="ownerType"
|
||||||
/>
|
label="Vendor"
|
||||||
</FormControl>
|
/>
|
||||||
<FormControl variant="standard" fullWidth>
|
<FormControlLabel
|
||||||
<InputLabel shrink htmlFor="password">
|
value="attorney"
|
||||||
Confirm Password
|
control={<Radio />}
|
||||||
</InputLabel>
|
name="ownerType"
|
||||||
<TextField
|
label="Attorney"
|
||||||
variant="filled"
|
/>
|
||||||
placeholder="********"
|
<FormControlLabel
|
||||||
onChange={(event) => formik.setFieldValue('password2', event.target.value)}
|
value="real_estate_agent"
|
||||||
type={showPassword2 ? 'text' : 'password'}
|
control={<Radio />}
|
||||||
id="password"
|
name="ownerType"
|
||||||
InputProps={{
|
label="Real Estate Agent"
|
||||||
endAdornment: (
|
/>
|
||||||
<InputAdornment position="end">
|
</RadioGroup>
|
||||||
<IconButton
|
</FormControl>
|
||||||
aria-label="toggle password visibility"
|
<FormControl variant="standard" fullWidth>
|
||||||
onClick={handleClickShowPassword2}
|
<InputLabel shrink htmlFor="password">
|
||||||
edge="end"
|
Password
|
||||||
sx={{
|
</InputLabel>
|
||||||
color: 'text.secondary',
|
<TextField
|
||||||
}}
|
variant="filled"
|
||||||
>
|
placeholder="********"
|
||||||
{showPassword ? (
|
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
type={showPassword ? 'text' : 'password'}
|
||||||
) : (
|
id="password"
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
InputProps={{
|
||||||
)}
|
endAdornment: (
|
||||||
</IconButton>
|
<InputAdornment position="end">
|
||||||
</InputAdornment>
|
<IconButton
|
||||||
),
|
aria-label="toggle password visibility"
|
||||||
}}
|
onClick={handleClickShowPassword}
|
||||||
/>
|
edge="end"
|
||||||
</FormControl>
|
sx={{
|
||||||
<Button variant="contained" type={'submit'} fullWidth>
|
color: 'text.secondary',
|
||||||
Sign up
|
}}
|
||||||
</Button>
|
>
|
||||||
</Form>
|
{showPassword ? (
|
||||||
|
<IconifyIcon icon="ic:baseline-key-off" />
|
||||||
)}
|
) : (
|
||||||
</Formik>
|
<IconifyIcon icon="ic:baseline-key" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</InputAdornment>
|
||||||
Already have an account ?{' '}
|
),
|
||||||
<Link
|
}}
|
||||||
href="/authentication/login"
|
/>
|
||||||
underline="hover"
|
</FormControl>
|
||||||
fontSize={(theme) => theme.typography.body1.fontSize}
|
<FormControl variant="standard" fullWidth>
|
||||||
>
|
<InputLabel shrink htmlFor="password">
|
||||||
Log in
|
Confirm Password
|
||||||
</Link>
|
</InputLabel>
|
||||||
</Typography>
|
<TextField
|
||||||
</Stack>
|
variant="filled"
|
||||||
</Stack>
|
placeholder="********"
|
||||||
<Suspense
|
onChange={(event) => setFieldValue('password2', event.target.value)}
|
||||||
fallback={
|
type={showPassword2 ? 'text' : 'password'}
|
||||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
id="password"
|
||||||
}
|
InputProps={{
|
||||||
>
|
endAdornment: (
|
||||||
<Image
|
<InputAdornment position="end">
|
||||||
alt="Signup banner"
|
<IconButton
|
||||||
src={signupBanner}
|
aria-label="toggle password visibility"
|
||||||
sx={{
|
onClick={handleClickShowPassword2}
|
||||||
width: 0.5,
|
edge="end"
|
||||||
display: { xs: 'none', md: 'block' },
|
sx={{
|
||||||
}}
|
color: 'text.secondary',
|
||||||
/>
|
}}
|
||||||
</Suspense>
|
>
|
||||||
</Stack>
|
{showPassword ? (
|
||||||
);
|
<IconifyIcon icon="ic:baseline-key-off" />
|
||||||
};
|
) : (
|
||||||
|
<IconifyIcon icon="ic:baseline-key" />
|
||||||
export default SignUp;
|
)}
|
||||||
|
</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
|
||||||
|
href="/authentication/login"
|
||||||
|
underline="hover"
|
||||||
|
fontSize={(theme) => theme.typography.body1.fontSize}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt="Signup banner"
|
||||||
|
src={signupBanner}
|
||||||
|
sx={{
|
||||||
|
width: 0.5,
|
||||||
|
display: { xs: 'none', md: 'block' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignUp;
|
||||||
|
|||||||
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -11,119 +11,220 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} 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 => {
|
import { useNavigate } from 'react-router-dom';
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
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 (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
bgcolor="background.paper"
|
bgcolor="background.paper"
|
||||||
boxShadow={(theme) => theme.shadows[3]}
|
boxShadow={(theme) => theme.shadows[3]}
|
||||||
height={560}
|
minHeight={560}
|
||||||
width={{ md: 960 }}
|
width={{ md: 960 }}
|
||||||
>
|
>
|
||||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
<Stack m={2.5} gap={10}>
|
||||||
<Link href="/" height="fit-content">
|
<Stack alignItems="center" gap={2.5} mx="auto">
|
||||||
<Image src={logo} width={82.6} />
|
<Typography variant="h1">Terms Of Service</Typography>
|
||||||
</Link>
|
|
||||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
|
||||||
<Typography variant="h3">Login</Typography>
|
|
||||||
<FormControl variant="standard" fullWidth>
|
<FormControl variant="standard" fullWidth>
|
||||||
<InputLabel shrink htmlFor="email">
|
<Typography variant="caption">Last Updated: July 15, 2025</Typography>
|
||||||
Email
|
<Typography variant="body1">
|
||||||
</InputLabel>
|
Welcome to [Your Website Name]! These Terms of Service ("Terms") govern your access to
|
||||||
<TextField
|
and use of the [Your Website Name] website portal (the "Service"), operated by [Your
|
||||||
variant="filled"
|
Company Name] ("we," "us," or "our").
|
||||||
placeholder="Enter your email"
|
</Typography>
|
||||||
id="email"
|
<Typography variant="body1">
|
||||||
InputProps={{
|
By accessing or using the Service, you agree to be bound by these Terms and our
|
||||||
endAdornment: (
|
Privacy Policy. If you do not agree to these Terms, please do not use the Service.
|
||||||
<InputAdornment position="end">
|
</Typography>
|
||||||
<IconifyIcon icon="ic:baseline-email" />
|
<Typography>
|
||||||
</InputAdornment>
|
<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
|
||||||
</FormControl>
|
your jurisdiction to use the Service. If you are accessing or using the Service on
|
||||||
<FormControl variant="standard" fullWidth>
|
behalf of a company or other legal entity, you represent that you have the authority
|
||||||
<InputLabel shrink htmlFor="password">
|
to bind such entity to these Terms.
|
||||||
Password
|
</Typography>
|
||||||
</InputLabel>
|
<Typography variant="h4">2. Changes to Terms</Typography>
|
||||||
<TextField
|
<Typography variant="body1">
|
||||||
variant="filled"
|
We reserve the right, at our sole discretion, to modify or replace these Terms at
|
||||||
placeholder="********"
|
any time. If a revision is material, we will provide at least 30 days' notice prior
|
||||||
type={showPassword ? 'text' : 'password'}
|
to any new terms taking effect. What constitutes a material change will be
|
||||||
id="password"
|
determined at our sole discretion. By continuing to access or use our Service after
|
||||||
InputProps={{
|
those revisions become effective, you agree to be bound by the revised terms.
|
||||||
endAdornment: (
|
</Typography>
|
||||||
<InputAdornment position="end">
|
<Typography variant="h4">3. Privacy Policy</Typography>
|
||||||
<IconButton
|
<Typography variant="body1">
|
||||||
aria-label="toggle password visibility"
|
Your use of the Service is also governed by our Privacy Policy, which explains how
|
||||||
onClick={handleClickShowPassword}
|
we collect, use, and disclose information about you. Please review our Privacy
|
||||||
edge="end"
|
Policy at [Link to your Privacy Policy] to understand our practices.
|
||||||
sx={{
|
</Typography>
|
||||||
color: 'text.secondary',
|
<Typography variant="h4">4. User Accounts</Typography>
|
||||||
}}
|
<Typography variant="body1">
|
||||||
>
|
Account Creation: To access certain features of the Service, you may be required to
|
||||||
{showPassword ? (
|
register for an account. You agree to provide accurate, current, and complete
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
information during the registration process and to update such information to keep
|
||||||
) : (
|
it accurate, current, and complete.
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
</Typography>
|
||||||
)}
|
<Typography variant="body1">
|
||||||
</IconButton>
|
Account Security: You are responsible for safeguarding the password that you use to
|
||||||
</InputAdornment>
|
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>
|
</FormControl>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
sx={{
|
sx={{
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
></Typography>
|
||||||
<Link href="/authentication/forgot-password" underline="hover">
|
|
||||||
Forget password
|
<Button variant="contained" fullWidth onClick={handleSignTOS}>
|
||||||
</Link>
|
Acknowledge
|
||||||
</Typography>
|
|
||||||
<Button variant="contained" fullWidth>
|
|
||||||
Log in
|
|
||||||
</Button>
|
</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>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||||
}
|
}
|
||||||
>
|
></Suspense>
|
||||||
<Image
|
|
||||||
alt="Login banner"
|
|
||||||
src={loginBanner}
|
|
||||||
sx={{
|
|
||||||
width: 0.5,
|
|
||||||
display: { xs: 'none', md: 'block' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default TermsOfService;
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
export const rootPaths = {
|
export const rootPaths = {
|
||||||
homeRoot: '',
|
homeRoot: '',
|
||||||
pagesRoot: 'pages',
|
pagesRoot: 'pages',
|
||||||
applicationsRoot: 'applications',
|
applicationsRoot: 'applications',
|
||||||
ecommerceRoot: 'ecommerce',
|
ecommerceRoot: 'ecommerce',
|
||||||
authRoot: 'authentication',
|
authRoot: 'authentication',
|
||||||
notificationsRoot: 'notifications',
|
notificationsRoot: 'notifications',
|
||||||
calendarRoot: 'calendar',
|
calendarRoot: 'calendar',
|
||||||
messageRoot: 'messages',
|
messageRoot: 'conversations',
|
||||||
errorRoot: 'error',
|
errorRoot: 'error',
|
||||||
educationRoot: 'education',
|
educationRoot: 'education',
|
||||||
propertyRoot: 'property',
|
propertyRoot: 'property',
|
||||||
vendorsRoot: 'vendors',
|
vendorsRoot: 'vendors',
|
||||||
termsOfServiceRoot: 'terms-of-service',
|
termsOfServiceRoot: 'terms-of-service',
|
||||||
};
|
toolsRoot: 'tools',
|
||||||
|
profileRoot: 'profile',
|
||||||
export default {
|
offersRoot: 'offers',
|
||||||
home: `/${rootPaths.homeRoot}`,
|
bidsRoot: 'bids',
|
||||||
login: `/${rootPaths.authRoot}/login`,
|
vendorBidsRoot: 'vendor-bids',
|
||||||
signup: `/${rootPaths.authRoot}/sign-up`,
|
upgradeRoot: 'upgrade',
|
||||||
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
};
|
||||||
forgotPassword: `/${rootPaths.authRoot}/forgot-password`,
|
|
||||||
404: `/${rootPaths.errorRoot}/404`,
|
export default {
|
||||||
education: `/${rootPaths.educationRoot}`,
|
home: `/${rootPaths.homeRoot}`,
|
||||||
educationLesson: `/${rootPaths.educationRoot}/lesson`,
|
login: `/${rootPaths.authRoot}/login`,
|
||||||
property: `/${rootPaths.propertyRoot}`,
|
signup: `/${rootPaths.authRoot}/sign-up`,
|
||||||
vendors: `/${rootPaths.vendorsRoot}`,
|
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
||||||
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
|
forgotPassword: `/${rootPaths.authRoot}/forgot-password`,
|
||||||
};
|
404: `/${rootPaths.errorRoot}/404`,
|
||||||
|
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,203 +1,397 @@
|
|||||||
import { ReactNode, Suspense, lazy, useContext } from 'react';
|
import { ReactNode, Suspense, lazy, useContext } from 'react';
|
||||||
import { Navigate, Outlet, RouteObject, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
import {
|
||||||
|
Navigate,
|
||||||
import paths, { rootPaths } from './paths';
|
Outlet,
|
||||||
|
RouteObject,
|
||||||
import PageLoader from '../components/loading/PageLoader';
|
RouterProvider,
|
||||||
import Splash from 'components/loading/Splash';
|
createBrowserRouter,
|
||||||
import Education from 'pages/Education/Education';
|
useNavigate,
|
||||||
import Property from 'pages/Property/Property';
|
} from 'react-router-dom';
|
||||||
import Vendors from 'pages/Vendors/Vendors';
|
|
||||||
import EducationDetail from 'components/sections/dashboard/Home/Education/EducationDetail';
|
import paths, { rootPaths } from './paths';
|
||||||
import TermsOfService from 'pages/home/TermsOfService';
|
|
||||||
import { AuthContext, AuthProvider } from 'contexts/AuthContext';
|
import PageLoader from '../components/loading/PageLoader';
|
||||||
|
import Splash from 'components/loading/Splash';
|
||||||
const App = lazy(() => import('App'));
|
import Education from 'pages/Education/Education';
|
||||||
const MainLayout = lazy(async () => {
|
import Property from 'pages/Property/Property';
|
||||||
return Promise.all([
|
import Vendors from 'pages/Vendors/Vendors';
|
||||||
import('layouts/main-layout'),
|
import EducationDetail from 'components/sections/dashboard/Home/Education/EducationDetail';
|
||||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
import TermsOfService from 'pages/home/TermsOfService';
|
||||||
]).then(([moduleExports]) => moduleExports);
|
import { AuthContext, AuthProvider } from 'contexts/AuthContext';
|
||||||
});
|
import AmoritizationTable from 'pages/Tools/AmoritizationTable';
|
||||||
const AuthLayout = lazy(async () => {
|
import MortgageCalculator from 'pages/Tools/MortgageCalculator';
|
||||||
return Promise.all([
|
import HomeAffordability from 'pages/Tools/HomeAffordability';
|
||||||
import('layouts/auth-layout'),
|
import Messages from 'pages/Messages/Messages';
|
||||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
import Offers from 'pages/Offers/Offers';
|
||||||
]).then(([moduleExports]) => moduleExports);
|
import NetTermsSheet from 'pages/Tools/NetTermsSheet';
|
||||||
});
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import ProfilePage from 'pages/Profile/Profile';
|
||||||
const Error404 = lazy(async () => {
|
import Dashboard from 'pages/home/Dashboard';
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
|
||||||
return import('pages/errors/Error404');
|
import PropertySearchPage from 'pages/Property/PropertySearchPage';
|
||||||
});
|
import BidsPage from 'pages/Bids/Bids';
|
||||||
|
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
|
||||||
const Sales = lazy(async () => {
|
import UpgradePage from 'pages/Upgrade/UpgradePage';
|
||||||
return Promise.all([
|
|
||||||
import('pages/home/Sales'),
|
const App = lazy(() => import('App'));
|
||||||
new Promise((resolve) => setTimeout(resolve, 500)),
|
const MainLayout = lazy(async () => {
|
||||||
]).then(([moduleExports]) => moduleExports);
|
return Promise.all([
|
||||||
});
|
import('layouts/main-layout'),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||||
const Login = lazy(async () => import('pages/authentication/Login'));
|
]).then(([moduleExports]) => moduleExports);
|
||||||
const SignUp = lazy(async () => import('pages/authentication/SignUp'));
|
});
|
||||||
|
const AuthLayout = lazy(async () => {
|
||||||
const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword'));
|
return Promise.all([
|
||||||
const ForgotPassword = lazy(async () => import('pages/authentication/ForgotPassword'));
|
import('layouts/auth-layout'),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||||
type ProtectedRouteProps = {
|
]).then(([moduleExports]) => moduleExports);
|
||||||
children?: ReactNode;
|
});
|
||||||
}
|
|
||||||
|
const Error404 = lazy(async () => {
|
||||||
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
const { authenticated, loading } = useContext(AuthContext);
|
return import('pages/errors/Error404');
|
||||||
|
});
|
||||||
if (!authenticated && !loading) {
|
|
||||||
return <Navigate to="/authentication/login" replace />;
|
const Login = lazy(async () => import('pages/authentication/Login'));
|
||||||
}
|
const SignUp = lazy(async () => import('pages/authentication/SignUp'));
|
||||||
|
|
||||||
return children;
|
const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword'));
|
||||||
};
|
const ForgotPassword = lazy(async () => import('pages/authentication/ForgotPassword'));
|
||||||
|
|
||||||
const routes: RouteObject[] = [
|
type ProtectedRouteProps = {
|
||||||
{
|
children?: ReactNode;
|
||||||
element: (
|
};
|
||||||
<Suspense fallback={<Splash />}>
|
|
||||||
<App />
|
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||||
</Suspense>
|
const navigate = useNavigate();
|
||||||
),
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
children: [
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
{
|
|
||||||
path: rootPaths.homeRoot,
|
if (!loading) {
|
||||||
element: (
|
if (!authenticated) {
|
||||||
<ProtectedRoute>
|
return <Navigate to="/authentication/login" replace />;
|
||||||
<MainLayout>
|
} else if (!accountLoading && account) {
|
||||||
<Suspense fallback={<PageLoader />}>
|
console.log(account);
|
||||||
<Outlet />
|
if (!account.tos_signed) {
|
||||||
</Suspense>
|
console.log('go to tos');
|
||||||
</MainLayout>
|
//navigate('/terms-of-service');
|
||||||
</ProtectedRoute>
|
return <Navigate to="/terms-of-service" replace />;
|
||||||
),
|
}
|
||||||
children: [
|
}
|
||||||
{
|
}
|
||||||
path: paths.home,
|
|
||||||
element: <Sales />,
|
return children;
|
||||||
},
|
};
|
||||||
],
|
|
||||||
},
|
const routes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
path: rootPaths.authRoot,
|
element: (
|
||||||
element: (
|
<Suspense fallback={<Splash />}>
|
||||||
<AuthLayout>
|
<App />
|
||||||
<Suspense fallback={<PageLoader />}>
|
</Suspense>
|
||||||
<Outlet />
|
),
|
||||||
</Suspense>
|
children: [
|
||||||
</AuthLayout>
|
{
|
||||||
),
|
path: rootPaths.homeRoot,
|
||||||
children: [
|
element: (
|
||||||
{
|
<ProtectedRoute>
|
||||||
path: paths.login,
|
<MainLayout>
|
||||||
element: <Login />,
|
<Suspense fallback={<PageLoader />}>
|
||||||
},
|
<Outlet />
|
||||||
{
|
</Suspense>
|
||||||
path: paths.signup,
|
</MainLayout>
|
||||||
element: <SignUp />,
|
</ProtectedRoute>
|
||||||
},
|
),
|
||||||
{
|
children: [
|
||||||
path: paths.resetPassword,
|
{
|
||||||
element: <ResetPassword />,
|
path: paths.home,
|
||||||
},
|
element: <Dashboard />,
|
||||||
{
|
//element: <Sales />,
|
||||||
path: paths.forgotPassword,
|
},
|
||||||
element: <ForgotPassword />,
|
],
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
},
|
path: rootPaths.authRoot,
|
||||||
// {
|
element: (
|
||||||
// path: rootPaths.termsOfServiceRoot,
|
<AuthLayout>
|
||||||
// element: (
|
<Suspense fallback={<PageLoader />}>
|
||||||
// <AuthLayout>
|
<Outlet />
|
||||||
// <Suspense fallback={<PageLoader />}>
|
</Suspense>
|
||||||
// <Outlet />
|
</AuthLayout>
|
||||||
// </Suspense>
|
),
|
||||||
// </AuthLayout>
|
children: [
|
||||||
// ),
|
{
|
||||||
// children: [
|
path: paths.login,
|
||||||
// {
|
element: <Login />,
|
||||||
// path: paths.login,
|
},
|
||||||
// element: <TermsOfService />,
|
{
|
||||||
// },
|
path: paths.signup,
|
||||||
// ]
|
element: <SignUp />,
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
path: rootPaths.propertyRoot,
|
path: paths.resetPassword,
|
||||||
|
element: <ResetPassword />,
|
||||||
element: (
|
},
|
||||||
<ProtectedRoute>
|
{
|
||||||
<MainLayout>
|
path: paths.forgotPassword,
|
||||||
<Suspense fallback={<PageLoader />}>
|
element: <ForgotPassword />,
|
||||||
<Outlet />
|
},
|
||||||
</Suspense>
|
],
|
||||||
</MainLayout>
|
},
|
||||||
</ProtectedRoute>
|
// {
|
||||||
),
|
// path: rootPaths.termsOfServiceRoot,
|
||||||
children: [
|
// element: (
|
||||||
{
|
// <AuthLayout>
|
||||||
path: paths.property,
|
// <Suspense fallback={<PageLoader />}>
|
||||||
element: <Property />,
|
// <Outlet />
|
||||||
},
|
// </Suspense>
|
||||||
],
|
// </AuthLayout>
|
||||||
},
|
// ),
|
||||||
{
|
// children: [
|
||||||
path: rootPaths.educationRoot,
|
// {
|
||||||
|
// path: paths.login,
|
||||||
element: (
|
// element: <TermsOfService />,
|
||||||
<ProtectedRoute>
|
// },
|
||||||
<MainLayout>
|
// ]
|
||||||
<Suspense fallback={<PageLoader />}>
|
// },
|
||||||
<Outlet />
|
{
|
||||||
</Suspense>
|
path: rootPaths.propertyRoot,
|
||||||
</MainLayout>
|
|
||||||
</ProtectedRoute>
|
element: (
|
||||||
),
|
<ProtectedRoute>
|
||||||
children: [
|
<MainLayout>
|
||||||
{
|
<Suspense fallback={<PageLoader />}>
|
||||||
path: paths.education,
|
<Outlet />
|
||||||
element: <Education />,
|
</Suspense>
|
||||||
},
|
</MainLayout>
|
||||||
{
|
</ProtectedRoute>
|
||||||
path: paths.educationLesson,
|
),
|
||||||
element: <EducationDetail />,
|
children: [
|
||||||
},
|
{
|
||||||
],
|
path: paths.property,
|
||||||
},
|
element: <Property />,
|
||||||
{
|
},
|
||||||
path: rootPaths.vendorsRoot,
|
{
|
||||||
|
path: paths.propertyDetail,
|
||||||
element: (
|
element: <PropertyDetailPage />,
|
||||||
<ProtectedRoute>
|
},
|
||||||
<MainLayout>
|
{
|
||||||
<Suspense fallback={<PageLoader />}>
|
path: paths.propertySearch,
|
||||||
<Outlet />
|
element: <PropertySearchPage />,
|
||||||
</Suspense>
|
},
|
||||||
</MainLayout>
|
],
|
||||||
</ProtectedRoute>
|
},
|
||||||
),
|
{
|
||||||
children: [
|
path: rootPaths.termsOfServiceRoot,
|
||||||
{
|
element: (
|
||||||
path: paths.vendors,
|
<MainLayout>
|
||||||
element: <Vendors />,
|
<Suspense fallback={<PageLoader />}>
|
||||||
},
|
<Outlet />
|
||||||
],
|
</Suspense>
|
||||||
},
|
</MainLayout>
|
||||||
{
|
),
|
||||||
path: '*',
|
children: [
|
||||||
element: <Error404 />,
|
{
|
||||||
},
|
path: paths.termsOfService,
|
||||||
],
|
element: <TermsOfService />,
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
},
|
||||||
const router = createBrowserRouter(routes, { basename: '/elegent' });
|
{
|
||||||
|
path: rootPaths.educationRoot,
|
||||||
|
|
||||||
export default router;
|
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: '*',
|
||||||
|
element: <Error404 />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['@emotion/react', '@emotion/styled', '@mui/material/Tooltip'],
|
include: ['@emotion/react', '@emotion/styled', '@mui/material/Tooltip'],
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths(), react()],
|
plugins: [tsconfigPaths(), react()],
|
||||||
preview: {
|
preview: {
|
||||||
port: 5000,
|
port: 5000,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
base: '/elegent',
|
base: '/',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user