Compare commits
3 Commits
f05b05420d
...
a3675c2585
| Author | SHA1 | Date | |
|---|---|---|---|
| a3675c2585 | |||
| 86b1eaf6f7 | |||
| 508d1179dc |
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
/dist # Common output directory for TypeScript builds
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.tsbuildinfo # TypeScript build info file
|
||||||
|
.eslintcache # ESLint cache file
|
||||||
2
ditch-the-agent/.env
Normal file
2
ditch-the-agent/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
|
||||||
|
REAL_ESTATE_API_KEY=AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70
|
||||||
3
ditch-the-agent/.env.beta
Normal file
3
ditch-the-agent/.env.beta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=https://beta.backend.ditchtheagent.com/api/
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
USE_LIVE_DATA=false
|
||||||
3
ditch-the-agent/.env.development
Normal file
3
ditch-the-agent/.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=http://127.0.0.1:8010/api/
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
USE_LIVE_DATA=false
|
||||||
3
ditch-the-agent/.env.production
Normal file
3
ditch-the-agent/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=https://backend.ditchtheagent.com/api/
|
||||||
|
ENABLE_REGISTRATION=false
|
||||||
|
USE_LIVE_DATA=true
|
||||||
1708
ditch-the-agent/package-lock.json
generated
1708
ditch-the-agent/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"build:beta": "tsc && vite build --mode beta",
|
||||||
|
"build:prod": "tsc && vite build --mode production",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
||||||
@@ -14,13 +16,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.11.5",
|
||||||
"@mui/material": "^5.15.14",
|
"@mui/icons-material": "^7.3.2",
|
||||||
|
"@mui/material": "^7.3.2",
|
||||||
"@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",
|
||||||
|
"@mui/x-date-pickers": "^8.11.2",
|
||||||
|
"@react-google-maps/api": "^2.20.7",
|
||||||
|
"@types/zxcvbn": "^4.4.5",
|
||||||
|
"@vis.gl/react-google-maps": "^1.5.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
@@ -29,7 +38,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"simplebar-react": "^3.2.5"
|
"simplebar-react": "^3.2.5",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/react": "^4.1.1",
|
"@iconify/react": "^4.1.1",
|
||||||
@@ -39,6 +49,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,19 +1,31 @@
|
|||||||
import axios from "axios"
|
import axios from 'axios';
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_API_URL;
|
||||||
|
console.log(baseURL);
|
||||||
|
|
||||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
export const axiosRealEstateApi = axios.create({
|
||||||
//const baseURL = 'https://backend.ditchtheagent.com/api/';
|
baseURL: 'https://api.realestateapi.com/v2/',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': import.meta.env.REAL_ESTATE_API_KEY,
|
||||||
|
'X-User-Id': 'UniqueUserIdentifier',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const axiosWalkScoreApiInstance = axios.create({
|
||||||
|
baseURL: 'https://api.walkscore.com/',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
export const axiosInstance = axios.create({
|
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({
|
||||||
@@ -21,8 +33,8 @@ export const cleanAxiosInstance = axios.create({
|
|||||||
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({
|
||||||
@@ -32,30 +44,30 @@ export const axiosInstanceCSRF = axios.create({
|
|||||||
'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" &&
|
if (
|
||||||
|
error.response.data.code === 'token_not_valid' &&
|
||||||
error.response.status == 401 &&
|
error.response.status == 401 &&
|
||||||
error.response.statusText == 'Unauthorized')
|
error.response.statusText == 'Unauthorized'
|
||||||
{
|
) {
|
||||||
const refresh_token = localStorage.getItem('refresh_token');
|
const refresh_token = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
if (refresh_token) {
|
if (refresh_token) {
|
||||||
@@ -65,7 +77,9 @@ axiosInstance.interceptors.response.use(
|
|||||||
//console.log(tokenParts.exp)
|
//console.log(tokenParts.exp)
|
||||||
|
|
||||||
if (tokenParts.exp > now) {
|
if (tokenParts.exp > now) {
|
||||||
return axiosInstance.post('/token/refresh/', {refresh: refresh_token}).then((response) => {
|
return axiosInstance
|
||||||
|
.post('/token/refresh/', { refresh: refresh_token })
|
||||||
|
.then((response) => {
|
||||||
localStorage.setItem('access_token', response.data.access);
|
localStorage.setItem('access_token', response.data.access);
|
||||||
localStorage.setItem('refresh_token', response.data.refresh);
|
localStorage.setItem('refresh_token', response.data.refresh);
|
||||||
|
|
||||||
@@ -73,24 +87,19 @@ axiosInstance.interceptors.response.use(
|
|||||||
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
|
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||||
|
|
||||||
return axiosInstance(originalRequest);
|
return axiosInstance(originalRequest);
|
||||||
}).catch(err => {
|
})
|
||||||
console.log(err)
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
console.log('Refresh token is expired');
|
console.log('Refresh token is expired');
|
||||||
window.location.href = '/signin/';
|
window.location.href = '/authentication/login/';
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Refresh token not available');
|
console.log('Refresh token not available');
|
||||||
window.location.href = '/signin/';
|
window.location.href = '/authentication/login/';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
28
ditch-the-agent/src/components/CategoryGridTemplate.tsx
Normal file
28
ditch-the-agent/src/components/CategoryGridTemplate.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// src/templates/CategoryGridTemplate.tsx
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Grid } from '@mui/material';
|
||||||
|
import { GenericCategory } from 'types';
|
||||||
|
|
||||||
|
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
|
||||||
|
categories: TCategory[];
|
||||||
|
onSelectCategory: (categoryId: string) => void;
|
||||||
|
renderCategoryCard: (category: TCategory, onSelect: (categoryId: string) => void) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryGridTemplate<TCategory extends GenericCategory>({
|
||||||
|
categories,
|
||||||
|
onSelectCategory,
|
||||||
|
renderCategoryCard,
|
||||||
|
}: CategoryGridTemplateProps<TCategory>) {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={category.id}>
|
||||||
|
{renderCategoryCard(category, onSelectCategory)}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryGridTemplate;
|
||||||
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,7 +30,6 @@ 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"
|
||||||
@@ -30,11 +50,6 @@ const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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,12 +392,10 @@ 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);
|
||||||
@@ -362,10 +431,7 @@ const FloatingChatButton = (): ReactElement =>
|
|||||||
closeChat={closeChat}
|
closeChat={closeChat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FloatingChatButton;
|
export default FloatingChatButton;
|
||||||
106
ditch-the-agent/src/components/ItemListDetailTemplate.tsx
Normal file
106
ditch-the-agent/src/components/ItemListDetailTemplate.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// src/templates/ItemListDetailTemplate.tsx
|
||||||
|
import React, { useState, useEffect, ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { GenericCategory, GenericItem } from 'types';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
|
||||||
|
interface ItemListDetailTemplateProps<
|
||||||
|
TCategory extends GenericCategory,
|
||||||
|
TItem extends GenericItem,
|
||||||
|
> {
|
||||||
|
category: TCategory;
|
||||||
|
items: TItem[];
|
||||||
|
onBack: () => void;
|
||||||
|
renderListItem: (
|
||||||
|
item: TItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
onSelect: (itemId: string) => void,
|
||||||
|
) => ReactNode;
|
||||||
|
renderItemDetail: (item: TItem) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends GenericItem>({
|
||||||
|
category,
|
||||||
|
items,
|
||||||
|
onBack,
|
||||||
|
renderListItem,
|
||||||
|
renderItemDetail,
|
||||||
|
}: ItemListDetailTemplateProps<TCategory, TItem>) {
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
|
||||||
|
|
||||||
|
// Default to the first item in the list
|
||||||
|
let temp = null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
temp = items[0].id;
|
||||||
|
setSelectedItemId(items[0].id);
|
||||||
|
} else {
|
||||||
|
setSelectedItemId(null);
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
|
||||||
|
|
||||||
|
console.log(selectedItemId, selectedItem);
|
||||||
|
|
||||||
|
const handleItemSelect = (itemId: string) => {
|
||||||
|
setSelectedItemId(itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
<Stack direction="row">
|
||||||
|
<IconButton size="small" color="inherit" onClick={onBack} sx={{ mr: 1 }}>
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{category.name} List
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<List>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Box key={item.id}>
|
||||||
|
{renderListItem(item, selectedItem?.id === item.id, handleItemSelect)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
{selectedItem ? (
|
||||||
|
renderItemDetail(selectedItem)
|
||||||
|
) : (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Select an item to view details
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ItemListDetailTemplate;
|
||||||
61
ditch-the-agent/src/components/PasswordStrengthChecker.tsx
Normal file
61
ditch-the-agent/src/components/PasswordStrengthChecker.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
import { Box, LinearProgress, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface PasswordStrengthCheckerProps {
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasswordStrengthChecker: React.FC<PasswordStrengthCheckerProps> = ({ password }) => {
|
||||||
|
const strength = password ? zxcvbn(password).score : 0;
|
||||||
|
|
||||||
|
const getStrengthLabel = () => {
|
||||||
|
switch (strength) {
|
||||||
|
case 0:
|
||||||
|
return 'Weak';
|
||||||
|
case 1:
|
||||||
|
return 'Fair';
|
||||||
|
case 2:
|
||||||
|
return 'Good';
|
||||||
|
case 3:
|
||||||
|
return 'Strong';
|
||||||
|
case 4:
|
||||||
|
return 'Very Strong';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthColor = () => {
|
||||||
|
switch (strength) {
|
||||||
|
case 0:
|
||||||
|
return 'error';
|
||||||
|
case 1:
|
||||||
|
return 'warning';
|
||||||
|
case 2:
|
||||||
|
return 'info';
|
||||||
|
case 3:
|
||||||
|
return 'success';
|
||||||
|
case 4:
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(strength + 1) * 20}
|
||||||
|
color={getStrengthColor()}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
{getStrengthLabel()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordStrengthChecker;
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
ditch-the-agent/src/components/base/LoadingSkeleton.tsx
Normal file
36
ditch-the-agent/src/components/base/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
|
||||||
|
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
const LoadingSkeleton = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="lg"
|
||||||
|
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Grid container sx={{ height: '100%' }}>
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, sm: 6, md: 4 }}
|
||||||
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSkeleton;
|
||||||
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 size={{ 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 size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<FolderIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Active Cases
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<List>
|
||||||
|
{mockAttorneyCases.map((c) => (
|
||||||
|
<React.Fragment key={c.id}>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
{c.status === 'urgent' ? (
|
||||||
|
<WarningIcon color="warning" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleIcon color="success" />
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={c.title} secondary={`Deadline: ${c.deadline}`} />
|
||||||
|
</ListItem>
|
||||||
|
<Divider component="li" />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Button fullWidth sx={{ mt: 2 }}>
|
||||||
|
View All Cases
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
{/* Upcoming Deadlines Card */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<EventIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Upcoming Deadlines
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<List>
|
||||||
|
{mockAttorneyCases
|
||||||
|
.filter((c) => c.status === 'urgent')
|
||||||
|
.map((c) => (
|
||||||
|
<ListItem key={c.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<WarningIcon color="warning" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={c.title} secondary={`Due by: ${c.deadline}`} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Button fullWidth sx={{ mt: 2 }}>
|
||||||
|
View Calendar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
{/* Documents to Review Card */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<DescriptionIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Documents Requiring Action
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Contract for 123 Main St"
|
||||||
|
secondary="Needs your signature"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Title Report for 456 Oak Ave"
|
||||||
|
secondary="Awaiting your review"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttorneyDashboard;
|
||||||
@@ -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,384 @@
|
|||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
|
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
//==import Grid from '@mui/material/Unstable_Grid2';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { drawerWidth } from 'layouts/main-layout';
|
||||||
|
import { ProperyInfoCards } from '../Property/PropertyInfo';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import HouseIcon from '@mui/icons-material/House';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||||
|
import { EducationInfoCards } from '../Education/EducationInfo';
|
||||||
|
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
||||||
|
import { GridColDef } from '@mui/x-data-grid';
|
||||||
|
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
||||||
|
import { DashboardProps } from 'pages/home/Dashboard';
|
||||||
|
import SavedPropertiesTable from './SavedPropertiesTable';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||||
|
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [numBids, setNumBids] = useState<Number>(0);
|
||||||
|
const [numOffers, setNumOffers] = useState<Number>(0);
|
||||||
|
const [savedProperties, setSavedProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProperties = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setProperties(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchOffers = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/offers/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setNumOffers(data.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBids = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/bids/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setNumBids(data.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchSavedProperties = async () => {
|
||||||
|
try {
|
||||||
|
let expandedSavedProperties: PropertiesAPI[] = [];
|
||||||
|
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
|
||||||
|
await axiosInstance.get('/saved-properties/');
|
||||||
|
const requests = data.map((item) =>
|
||||||
|
axiosInstance.get(`/properties/${item.property}/?search=1`),
|
||||||
|
);
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
|
||||||
|
expandedSavedProperties = responses.map((response) => response.data);
|
||||||
|
console.log(expandedSavedProperties);
|
||||||
|
setSavedProperties(expandedSavedProperties);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchDocuments = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
|
||||||
|
console.log('documents', data);
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProperties();
|
||||||
|
fetchOffers();
|
||||||
|
fetchBids();
|
||||||
|
fetchSavedProperties();
|
||||||
|
fetchDocuments();
|
||||||
|
}, []);
|
||||||
|
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||||
|
`/properties/${updatedProperty.id}/`,
|
||||||
|
{
|
||||||
|
...updatedProperty,
|
||||||
|
owner: account.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updatedProperties = properties.map((item) => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, ...data };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
setProperties(updatedProperties);
|
||||||
|
setMessage({ type: 'success', text: 'Property has been updated' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProperty = async (propertyId: number) => {
|
||||||
|
try {
|
||||||
|
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||||
|
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||||
|
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentColumns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
headerName: 'ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
headerName: 'Title',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'action',
|
||||||
|
headerName: 'Action',
|
||||||
|
flex: 0,
|
||||||
|
renderCell: (params: GridRenderCellParams<Row, number>) => {
|
||||||
|
return (
|
||||||
|
<Button variant="contained" component="label">
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DocumentRows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Offer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Disclousre Form',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const numViews = properties.reduce((accum, currProperty) => {
|
||||||
|
return accum + currProperty.views;
|
||||||
|
}, 0);
|
||||||
|
const numSaves = properties.reduce((accum, currProperty) => {
|
||||||
|
return accum + currProperty.saves;
|
||||||
|
}, 0);
|
||||||
|
const savedPropertiesCardLength: number = savedProperties.length === 0 ? 6 : 12;
|
||||||
|
const documentsCardLength: number = documents.length === 0 ? 6 : 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
component="main"
|
||||||
|
columns={12}
|
||||||
|
spacing={3.75}
|
||||||
|
flexGrow={1}
|
||||||
|
pt={4.375}
|
||||||
|
pr={1.875}
|
||||||
|
pb={0}
|
||||||
|
sx={{
|
||||||
|
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||||
|
pl: { xs: 3.75, lg: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* quick states */}
|
||||||
|
{!account.profile_created && (
|
||||||
|
<Grid xs={12} key="profile-setup">
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
Please set up your <a href="/profile">profile</a>
|
||||||
|
</Alert>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid xs={12} sm={6} md={4} lg={3} key="active-listing-card">
|
||||||
|
<Card sx={{ display: 'flex' }} onClick={() => navigate('/profile')}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Stack direction="column" alignItems="center">
|
||||||
|
<HouseIcon />
|
||||||
|
<Typography variant="button">{properties.length}</Typography>
|
||||||
|
<Typography variant="caption">Active Listings</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} md={4} lg={3} key="num-views-card">
|
||||||
|
<Card sx={{ display: 'flex' }}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Stack direction="column" alignItems="center">
|
||||||
|
<VisibilityIcon />
|
||||||
|
<Typography>{numViews}</Typography>
|
||||||
|
<Typography variant="caption">Total Views</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} md={4} lg={3} key="total-saves-card">
|
||||||
|
<Card sx={{ display: 'flex' }}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Stack direction="column" alignItems="center">
|
||||||
|
<FavoriteIcon />
|
||||||
|
<Typography>{numSaves}</Typography>
|
||||||
|
<Typography variant="caption">Total Saves</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} md={4} lg={3} key="num-offers-card">
|
||||||
|
<Card sx={{ display: 'flex' }} onClick={() => navigate('/offers')}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Stack direction="column" alignItems="center">
|
||||||
|
<RequestQuoteIcon />
|
||||||
|
<Typography variant="h6">{numOffers}</Typography>
|
||||||
|
<Typography variant="caption">Total Offers</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} md={4} lg={3} key="num-bids-card">
|
||||||
|
<Card sx={{ display: 'flex' }} onClick={() => navigate('/bids')}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Stack direction="column" alignItems="center">
|
||||||
|
<RequestQuoteIcon />
|
||||||
|
<Typography variant="h6">{numBids}</Typography>
|
||||||
|
<Typography variant="caption">Total Bids</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{account.tier === 'basic' && (
|
||||||
|
<Grid xs={12} md={4}>
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Upgrade your account</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Unlock premium features to get more features and sell faster
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CardActionArea>
|
||||||
|
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
<Button>Learn More</Button>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{properties.length > 0 && (
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
{properties.map((item) => (
|
||||||
|
<Grid xs={12} key={item.id}>
|
||||||
|
<PropertyDetailCard
|
||||||
|
property={item}
|
||||||
|
isPublicPage={false}
|
||||||
|
onSave={handleSaveProperty}
|
||||||
|
isOwnerView={true}
|
||||||
|
onDelete={handleDeleteProperty}
|
||||||
|
/>
|
||||||
|
{/* <ProperyInfoCards property={item} /> */}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} md={documentsCardLength}>
|
||||||
|
<Card sx={{ display: 'flex' }}>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Documents Requiring Attention</Typography>
|
||||||
|
</Stack>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<Typography variant="caption">
|
||||||
|
There are no documents that require your attention at this point
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} md={savedPropertiesCardLength}>
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Saved Properties</Typography>
|
||||||
|
<Typography variant="caption">Keep track of the properties you have saved</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<SavedPropertiesTable savedProperties={savedProperties} />
|
||||||
|
</CardContent>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} md={12}>
|
||||||
|
{account.tier === 'premium' ? (
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Video Progress</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Complete our FSBO training to maximize your sale potential
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<EducationInfoCards />
|
||||||
|
</CardContent>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Video Progress</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Upgrade to get access to FSBO educational videos
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CardActionArea>
|
||||||
|
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
<Button>Learn More</Button>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyOwnerDashboard;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// src/pages/RealEstateAgentDashboardPage.tsx
|
||||||
|
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import GavelIcon from '@mui/icons-material/Gavel';
|
||||||
|
import EventAvailableIcon from '@mui/icons-material/EventAvailable';
|
||||||
|
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||||
|
import { DashboardProps } from 'pages/home/Dashboard';
|
||||||
|
|
||||||
|
// Mock Data for the Real Estate Agent Dashboard
|
||||||
|
interface AgentListing {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
status: 'active' | 'pending';
|
||||||
|
offers: number;
|
||||||
|
views: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentOffer {
|
||||||
|
id: number;
|
||||||
|
property_address: string;
|
||||||
|
offer_amount: number;
|
||||||
|
offer_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAgentListings: AgentListing[] = [
|
||||||
|
{ id: 1, address: '123 Main St, Anytown', status: 'active', offers: 3, views: 250 },
|
||||||
|
{ id: 2, address: '456 Oak Ave, Anytown', status: 'pending', offers: 1, views: 180 },
|
||||||
|
{ id: 3, address: '789 Pine Ln, Othertown', status: 'active', offers: 0, views: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAgentOffers: AgentOffer[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
property_address: '123 Main St, Anytown',
|
||||||
|
offer_amount: 510000,
|
||||||
|
offer_date: 'August 5, 2025',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Agent Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Listings Summary Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<HomeIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
My Listings
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{mockAgentListings.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1">Total Active Listings</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="body1">
|
||||||
|
Total Offers: {mockAgentListings.reduce((sum, l) => sum + l.offers, 0)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* New Offers Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
New Offers
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{mockAgentOffers.length > 0 ? (
|
||||||
|
<List dense>
|
||||||
|
{mockAgentOffers.map((offer) => (
|
||||||
|
<ListItem key={offer.id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={`$${offer.offer_amount.toLocaleString()}`}
|
||||||
|
secondary={`On ${offer.property_address}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body1">No new offers at this time.</Typography>
|
||||||
|
)}
|
||||||
|
<Button fullWidth sx={{ mt: 2 }}>
|
||||||
|
View All Offers
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Upcoming Showings Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<EventAvailableIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Upcoming Appointments
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PersonAddIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Showing for John Doe"
|
||||||
|
secondary="123 Main St - 2:00 PM today"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PersonAddIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Open House" secondary="789 Pine Ln - Sunday at 1:00 PM" />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<Button fullWidth sx={{ mt: 2 }}>
|
||||||
|
View My Calendar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Example of other cards */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Listing Performance
|
||||||
|
</Typography>
|
||||||
|
{/* A chart or a list of top-performing listings could go here */}
|
||||||
|
<Typography variant="body1">
|
||||||
|
123 Main St is your top-performing listing with 250 views.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstateAgentDashboard;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
|
|
||||||
|
interface SavedPropertiesTableProps {
|
||||||
|
savedProperties: PropertiesAPI[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SavedPropertiesTable: React.FC<SavedPropertiesTableProps> = ({ savedProperties }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getUpcomingOpenHouseDate = (property: PropertiesAPI): string => {
|
||||||
|
if (!property.open_houses || property.open_houses.length === 0) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const futureOpenHouses = property.open_houses.filter(
|
||||||
|
(openHouse) => new Date(openHouse.listed_date) >= today,
|
||||||
|
);
|
||||||
|
if (futureOpenHouses.length > 0) {
|
||||||
|
// Sort to get the soonest upcoming one
|
||||||
|
futureOpenHouses.sort(
|
||||||
|
(a, b) => new Date(a.listed_date).getTime() - new Date(b.listed_date).getTime(),
|
||||||
|
);
|
||||||
|
// Format the date for display
|
||||||
|
return new Date(futureOpenHouses[0].listed_date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
return 'No upcoming dates';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (savedProperties.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="h6" align="center">
|
||||||
|
You don't have any saved properties.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} sx={{ mt: 4 }}>
|
||||||
|
<Table sx={{ minWidth: 650 }} aria-label="saved properties table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Street Address</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Price</TableCell>
|
||||||
|
<TableCell>Upcoming Open House</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{savedProperties.map((property) => {
|
||||||
|
const displayPrice = property.listed_price
|
||||||
|
? `$${property.listed_price}`
|
||||||
|
: `$${property.market_value}`;
|
||||||
|
|
||||||
|
const openHouseDate = getUpcomingOpenHouseDate(property);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={property.id}>
|
||||||
|
<TableCell>{property.street}</TableCell>
|
||||||
|
<TableCell>{property.property_status}</TableCell>
|
||||||
|
<TableCell>{displayPrice}</TableCell>
|
||||||
|
<TableCell>{openHouseDate}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/property/${property.id}/?search=1`)}
|
||||||
|
>
|
||||||
|
View Listing
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SavedPropertiesTable;
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// src/pages/VendorDashboardPage.tsx
|
||||||
|
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import GavelIcon from '@mui/icons-material/Gavel';
|
||||||
|
import ChatBubbleIcon from '@mui/icons-material/ChatBubble';
|
||||||
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||||
|
import PaidIcon from '@mui/icons-material/Paid';
|
||||||
|
import { DashboardProps } from 'pages/home/Dashboard';
|
||||||
|
import { BidAPI, ConverationAPI, VendorAPI, VendorItem } from 'types';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import DashboardLoading from './DashboardLoading';
|
||||||
|
import DashboardErrorPage from './DashboardErrorPage';
|
||||||
|
import VendorDetail from '../Vendor/VendorDetail';
|
||||||
|
|
||||||
|
// Mock Data for the Vendor Dashboard
|
||||||
|
interface VendorDashboardData {
|
||||||
|
views: {
|
||||||
|
total: number;
|
||||||
|
last_30_days: number;
|
||||||
|
};
|
||||||
|
bids: {
|
||||||
|
total: number;
|
||||||
|
responded_to: number;
|
||||||
|
new_bids: number;
|
||||||
|
selected_for: number;
|
||||||
|
};
|
||||||
|
conversations: {
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockVendorData: VendorDashboardData = {
|
||||||
|
views: {
|
||||||
|
total: 5432,
|
||||||
|
last_30_days: 215,
|
||||||
|
},
|
||||||
|
bids: {
|
||||||
|
total: 45,
|
||||||
|
responded_to: 38,
|
||||||
|
new_bids: 7,
|
||||||
|
selected_for: 12,
|
||||||
|
},
|
||||||
|
conversations: {
|
||||||
|
unread: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||||
|
const [vendor, setVendor] = useState<VendorAPI | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||||
|
// bid data
|
||||||
|
const [numBids, setNumBids] = useState<Number>(0);
|
||||||
|
const [numBidResponses, setNumBidResponses] = useState<Number>(0);
|
||||||
|
const [numBidsSelected, setNumBidsSelected] = useState<Number>(0);
|
||||||
|
const [newBids, setNewBids] = useState<Number>(0);
|
||||||
|
const [numConverstaions, setNumConversations] = useState<Number>(0);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVendor = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setVendor(data[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchBids = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setNumBids(data.length);
|
||||||
|
let numBidResponsesFound: number = 0;
|
||||||
|
let numBidsSelectedFound: number = 0;
|
||||||
|
let newBidsFound: number = 0;
|
||||||
|
data.map((item) => {
|
||||||
|
let foundNewBid: boolean = true;
|
||||||
|
item.responses.map((response) => {
|
||||||
|
if (response.vendor.user.id === account.id) {
|
||||||
|
numBidResponsesFound = numBidResponsesFound + 1;
|
||||||
|
foundNewBid = false;
|
||||||
|
}
|
||||||
|
if (response.vendor.user.id === account.id && response.status === 'selected') {
|
||||||
|
numBidsSelectedFound = numBidsSelectedFound + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
newBidsFound = foundNewBid ? newBidsFound + 1 : newBidsFound;
|
||||||
|
});
|
||||||
|
|
||||||
|
setNumBidResponses(numBidResponsesFound);
|
||||||
|
setNumBidsSelected(numBidsSelectedFound);
|
||||||
|
setNewBids(newBidsFound);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchConversations = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<ConverationAPI[]> =
|
||||||
|
await axiosInstance.get('/conversations/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setNumConversations(data.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchVendor();
|
||||||
|
fetchBids();
|
||||||
|
fetchConversations();
|
||||||
|
}, []);
|
||||||
|
if (loadingData) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
}
|
||||||
|
if (vendor === null) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorItem: VendorItem = {
|
||||||
|
contactPerson: vendor.user.first_name + ' ' + vendor.user.last_name,
|
||||||
|
name: vendor.business_name,
|
||||||
|
description: vendor.description,
|
||||||
|
phone: vendor.phone_number,
|
||||||
|
email: vendor.user.email,
|
||||||
|
address: vendor.address,
|
||||||
|
vendorImageUrl: '',
|
||||||
|
rating: 5,
|
||||||
|
servicesOffered: vendor.services,
|
||||||
|
serviceAreas: vendor.service_areas,
|
||||||
|
categoryId: vendor.business_type,
|
||||||
|
latitude: Number(vendor.latitude),
|
||||||
|
longitude: Number(vendor.longitude),
|
||||||
|
views: vendor.views,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
|
||||||
|
Vendor Dashboard
|
||||||
|
</Typography>
|
||||||
|
{!account.profile_created && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
Please set up your <a href="/profile">profile</a>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Views Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<VisibilityIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Profile Views
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{vendor.views.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Total Views
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="body1">
|
||||||
|
{mockVendorData.views.last_30_days} in the last 30 days
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Bids Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<GavelIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Bids & Opportunities
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" justifyContent="space-around" textAlign="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{numBids}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Total Bids
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||||
|
<Chip label={newBids} color="error" size="small" />
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
New Bids
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Box display="flex" justifyContent="space-around" textAlign="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{numBidResponses}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Responded To
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{numBidsSelected}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Selected For
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Conversations Card */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<ChatBubbleIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Conversations
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box textAlign="center" mb={1}>
|
||||||
|
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
{numConverstaions}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1">Unread Messages</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
|
Stay on top of your messages to win more bids!
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Example of other cards that could be useful */}
|
||||||
|
|
||||||
|
{/*<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<PaidIcon color="primary" sx={{ fontSize: 36, mr: 1 }} />
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Recent Payments
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6">You have no new payments.</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>*/}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 12 }}>
|
||||||
|
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDashboard;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
|
||||||
|
import { PropertiesAPI, DocumentAPI, UserAPI, BidAPI } from 'types';
|
||||||
|
import AttorneyDocumentDialog from './Dialog/AttorneyDocumentDialog';
|
||||||
|
import PropertyOwnerDocumentDialog from './Dialog/PropertyOwnerDocumentDialog';
|
||||||
|
import VendorDocumentDialog from './Dialog/VendorDocumentDialog';
|
||||||
|
|
||||||
|
export type DocumentDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
bids: BidAPI[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddDocumentDialogProps = DocumentDialogProps & {
|
||||||
|
account: UserAPI;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddDocumentDialog = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
properties,
|
||||||
|
bids,
|
||||||
|
account,
|
||||||
|
}: AddDocumentDialogProps): ReactElement => {
|
||||||
|
if (account.user_type === 'property_owner') {
|
||||||
|
return (
|
||||||
|
<PropertyOwnerDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.user_type === 'vendor') {
|
||||||
|
return (
|
||||||
|
<VendorDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.user_type === 'attorney') {
|
||||||
|
return (
|
||||||
|
<AttorneyDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<Typography>Woops, we have encountered an error</Typography>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddDocumentDialog;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
const AttorneyDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
|
||||||
|
const [documentType, setDocumentType] = useState<string>('');
|
||||||
|
|
||||||
|
const getDialogContent = (document_type: string) => {
|
||||||
|
if (document_type === 'offer') {
|
||||||
|
} else {
|
||||||
|
return <Typography>Please select a document type</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<DialogTitle>Create a new document</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Select
|
||||||
|
placeholder="Select Document Type"
|
||||||
|
onChange={(e) => setDocumentType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="Select Document Type">Select Document Type</MenuItem>
|
||||||
|
<MenuItem value="attorney_engagment_letter">Attorney Engagment Letter</MenuItem>
|
||||||
|
<MenuItem value="sellers_disclosure">Seller Disclosure</MenuItem>
|
||||||
|
<MenuItem value="closing_document">Closing Document</MenuItem>
|
||||||
|
<MenuItem value="title_report">Title Report</MenuItem>
|
||||||
|
<MenuItem value="closing_disclosure">Closing Disclosure</MenuItem>
|
||||||
|
</Select>
|
||||||
|
{getDialogContent(documentType)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttorneyDocumentDialog;
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { formatCurrency } from 'utils';
|
||||||
|
import { BidAPI, PropertiesAPI, VendorAPI } from 'types';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
export interface HomeImprovementReceiptData {
|
||||||
|
propertyId: number;
|
||||||
|
vendorId: number | null;
|
||||||
|
dateOfWork: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
receiptFile?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeImprovementReceiptDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, receipt: HomeImprovementReceiptData) => void;
|
||||||
|
properties: PropertiesAPI[]; // The specific property for which the receipt is being submitted
|
||||||
|
vendors: VendorAPI[]; // List of available vendors for autocomplete
|
||||||
|
bids: BidAPI[];
|
||||||
|
homeImprovmentErrors: { [key: string]: string };
|
||||||
|
loadingVendors?: boolean; // Optional: indicate if vendors are still loading
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeImprovementReciptDialogContent = forwardRef<
|
||||||
|
HomeImprovementReceiptDialogContentHandle,
|
||||||
|
HomeImprovementReceiptDialogProps
|
||||||
|
>(({ onSubmit, properties, vendors, bids, homeImprovmentErrors, loadingVendors = false }, ref) => {
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
const [selectedBid, setSelectedBid] = useState<BidAPI | null>(null);
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<VendorAPI | null>(null);
|
||||||
|
const [dateOfWork, setDateOfWork] = useState<string>('');
|
||||||
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const [cost, setCost] = useState<string>('');
|
||||||
|
// const [receiptFile, setReceiptFile] = useState<File | null>(null); // For file upload
|
||||||
|
|
||||||
|
// Reset form when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedVendor(null);
|
||||||
|
setDateOfWork('');
|
||||||
|
setDescription('');
|
||||||
|
setCost('');
|
||||||
|
// setReceiptFile(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
// Basic validation
|
||||||
|
if (!selectedProperty) {
|
||||||
|
return;
|
||||||
|
} else if (!selectedProperty.id || !selectedVendor || !dateOfWork || !cost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiptData: HomeImprovementReceiptData = {
|
||||||
|
propertyId: selectedProperty?.id,
|
||||||
|
vendorId: selectedVendor.user.id,
|
||||||
|
dateOfWork,
|
||||||
|
description,
|
||||||
|
cost: parseFloat(cost),
|
||||||
|
// receiptFile, // Include if handling file upload
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit('contractor_recipt', receiptData);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(properties);
|
||||||
|
|
||||||
|
const getVendorOptionLabel = (option: VendorAPI) =>
|
||||||
|
`${option.business_name} (${option.business_type})`;
|
||||||
|
return (
|
||||||
|
<Grid size={{ xs: 12, md: 12 }}>
|
||||||
|
<Select
|
||||||
|
value={selectedProperty?.id || 'Pick a property'}
|
||||||
|
placeholder="Pick a property"
|
||||||
|
onChange={(e) => {
|
||||||
|
console.log(e.target.value);
|
||||||
|
console.log(properties);
|
||||||
|
console.log(properties.find((property) => property.id === Number(e.target.value)));
|
||||||
|
const foundProperty = properties.find(
|
||||||
|
(property) => property.id === Number(e.target.value),
|
||||||
|
);
|
||||||
|
setSelectedProperty(foundProperty);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<MenuItem value={property.id}>{property.address}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{selectedProperty && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
|
||||||
|
{selectedProperty.state} {selectedProperty.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
Record details of home improvements, maintenance, or repairs.
|
||||||
|
</Typography>
|
||||||
|
{bids.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={selectedBid}
|
||||||
|
label="Bid"
|
||||||
|
onChange={(e) => {
|
||||||
|
const foundBid = bids.find((bid) => bid.id === Number(e.target.value));
|
||||||
|
setSelectedBid(foundBid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bids.map((bid) => (
|
||||||
|
<MenuItem value={bid.id}>{bid.description}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={vendors}
|
||||||
|
getOptionLabel={getVendorOptionLabel}
|
||||||
|
loading={loadingVendors}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
setSelectedVendor(newValue);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Select Vendor"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<React.Fragment>
|
||||||
|
{loadingVendors ? (
|
||||||
|
<CircularProgress color="inherit" size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputAdornments?.end}
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Date of Work"
|
||||||
|
type="date"
|
||||||
|
value={dateOfWork}
|
||||||
|
onChange={(e) => setDateOfWork(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Description of Work Performed"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText="e.g., Replaced water heater, Repaired leaky faucet, Painted living room."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Cost of Work"
|
||||||
|
type="number"
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{/* Uncomment and implement if you need file uploads */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mt: 1, mb: 0.5 }}>
|
||||||
|
Upload Receipt (Optional)
|
||||||
|
</Typography>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setReceiptFile(e.target.files ? e.target.files[0] : null)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography>There are no bids to put the recipt against</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HomeImprovementReciptDialogContent;
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { formatCurrency } from 'utils'; // Assuming this utility function is available
|
||||||
|
import { axiosInstance } from '../../../../../../axiosApi'; // Assuming axiosInstance is configured
|
||||||
|
import { PropertiesAPI } from 'types'; // Assuming PropertiesAPI is defined in your types file
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
// Define the shape of the offer data to be submitted
|
||||||
|
export interface OfferData {
|
||||||
|
propertyId: number | null;
|
||||||
|
offerPrice: number;
|
||||||
|
closingDate?: string;
|
||||||
|
closingDays?: number;
|
||||||
|
contingencies: string;
|
||||||
|
parent_offer?: OfferData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the handle for the ref that the parent can use to interact with this component
|
||||||
|
export interface OfferDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the type for properties passed down
|
||||||
|
type OfferPropertyType = {
|
||||||
|
address: string;
|
||||||
|
marketValue: string;
|
||||||
|
property_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface for props of OfferDialogContent
|
||||||
|
interface OfferFormDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, offer: OfferData) => void; // Callback function to send data to parent
|
||||||
|
offerErrors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const OfferDialogContent = forwardRef<OfferDialogContentHandle, OfferFormDialogProps>(
|
||||||
|
({ onSubmit, offerErrors }, ref) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
|
const [filteredProperties, setFilteredProperties] = useState<OfferPropertyType[]>([]);
|
||||||
|
const [loadingProperties, setLoadingProperties] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// State for form fields
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<OfferPropertyType | null>(null);
|
||||||
|
const [offerPrice, setOfferPrice] = useState<string>('');
|
||||||
|
const [closingDate, setClosingDate] = useState<string>('');
|
||||||
|
const [closingDays, setClosingDays] = useState<string>('30'); // Default to 30 days
|
||||||
|
const [contingencies, setContingencies] = useState<string>('');
|
||||||
|
|
||||||
|
// Mortgage Calculation States
|
||||||
|
const [loanInterestRate, setLoanInterestRate] = useState<string>('7.0'); // Annual percentage
|
||||||
|
const [loanTermYears, setLoanTermYears] = useState<string>('30'); // Years
|
||||||
|
const [downPaymentPercentage, setDownPaymentPercentage] = useState<string>('20'); // Percentage of offer price
|
||||||
|
|
||||||
|
const [estimatedMonthlyPayment, setEstimatedMonthlyPayment] = useState<string>('N/A');
|
||||||
|
|
||||||
|
// Handle input change for the Autocomplete component, fetching properties
|
||||||
|
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
setLoadingProperties(true);
|
||||||
|
try {
|
||||||
|
// Fetch properties based on search input
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(
|
||||||
|
`/properties/?search=${newInputValue}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map API response to OfferPropertyType
|
||||||
|
const mappedResults: OfferPropertyType[] = data.map((item) => ({
|
||||||
|
address: item.address,
|
||||||
|
marketValue: item.market_value,
|
||||||
|
property_id: item.id,
|
||||||
|
}));
|
||||||
|
setFilteredProperties(mappedResults);
|
||||||
|
setOptions(mappedResults.map((item) => item.address)); // Set options for Autocomplete
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties:', error);
|
||||||
|
setOptions([]);
|
||||||
|
setFilteredProperties([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingProperties(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
setFilteredProperties([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect to recalculate mortgage whenever relevant inputs change
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateMortgage = () => {
|
||||||
|
const price = parseFloat(offerPrice);
|
||||||
|
const interestRate = parseFloat(loanInterestRate);
|
||||||
|
const termYears = parseFloat(loanTermYears);
|
||||||
|
const downPaymentPct = parseFloat(downPaymentPercentage);
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (
|
||||||
|
isNaN(price) ||
|
||||||
|
price <= 0 ||
|
||||||
|
isNaN(interestRate) ||
|
||||||
|
isNaN(termYears) ||
|
||||||
|
termYears <= 0 ||
|
||||||
|
isNaN(downPaymentPct)
|
||||||
|
) {
|
||||||
|
setEstimatedMonthlyPayment('N/A');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downPaymentAmount = price * (downPaymentPct / 100);
|
||||||
|
const principalLoanAmount = price - downPaymentAmount;
|
||||||
|
|
||||||
|
if (principalLoanAmount <= 0) {
|
||||||
|
setEstimatedMonthlyPayment(formatCurrency(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyInterestRate = interestRate / 100 / 12;
|
||||||
|
const numberOfPayments = termYears * 12;
|
||||||
|
|
||||||
|
let monthlyPayment = 0;
|
||||||
|
if (monthlyInterestRate === 0) {
|
||||||
|
// Handle zero interest rate scenario
|
||||||
|
monthlyPayment = principalLoanAmount / numberOfPayments;
|
||||||
|
} else {
|
||||||
|
// Standard mortgage formula
|
||||||
|
monthlyPayment =
|
||||||
|
(principalLoanAmount *
|
||||||
|
monthlyInterestRate *
|
||||||
|
Math.pow(1 + monthlyInterestRate, numberOfPayments)) /
|
||||||
|
(Math.pow(1 + monthlyInterestRate, numberOfPayments) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEstimatedMonthlyPayment(formatCurrency(monthlyPayment));
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateMortgage();
|
||||||
|
}, [offerPrice, loanInterestRate, loanTermYears, downPaymentPercentage]);
|
||||||
|
|
||||||
|
// Function to handle form submission
|
||||||
|
const submitForm = () => {
|
||||||
|
// Basic validation
|
||||||
|
if (!selectedProperty || !offerPrice) {
|
||||||
|
console.error('Validation Error: Please select a property and enter an offer price.');
|
||||||
|
// In a real app, you would display a user-friendly error message here (e.g., Snackbar)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the offer data object
|
||||||
|
const formData: OfferData = {
|
||||||
|
propertyId: selectedProperty.property_id,
|
||||||
|
property: selectedProperty.property_id,
|
||||||
|
offer_price: parseFloat(offerPrice),
|
||||||
|
closing_date: closingDate || undefined,
|
||||||
|
closing_days: closingDays ? parseInt(closingDays) : undefined,
|
||||||
|
contingencies,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the onSubmit prop to pass data to the parent
|
||||||
|
onSubmit('offer', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose the submitForm function via useImperativeHandle so parent can call it
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(selectedProperty);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Offer Form Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 7 }}>
|
||||||
|
<Autocomplete
|
||||||
|
value={selectedProperty?.address || null} // Display selected property address or null
|
||||||
|
options={options}
|
||||||
|
loading={loadingProperties}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
// Find the selected property object from filteredProperties
|
||||||
|
const selectedAddr = filteredProperties.find((item) => item.address === newValue);
|
||||||
|
setSelectedProperty(selectedAddr || null); // Set selected property or null
|
||||||
|
}}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the address to search for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Select Property"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<React.Fragment>
|
||||||
|
{loadingProperties ? <CircularProgress color="inherit" size={20} /> : null}
|
||||||
|
{params.InputProps.endAdornment} {/* Pass existing endAdornment */}
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Offer Price"
|
||||||
|
type="number"
|
||||||
|
value={offerPrice}
|
||||||
|
onChange={(e) => setOfferPrice(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
helperText={offerErrors.offer_price}
|
||||||
|
error={!!offerErrors.offer_price}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Closing Date (Optional)"
|
||||||
|
type="date"
|
||||||
|
value={closingDate}
|
||||||
|
onChange={(e) => setClosingDate(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
helperText={offerErrors.closing_date}
|
||||||
|
error={!!offerErrors.closing_date}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 1 }}>
|
||||||
|
OR
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Closing Duration (Days)"
|
||||||
|
type="number"
|
||||||
|
value={closingDays}
|
||||||
|
onChange={(e) => setClosingDays(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={offerErrors.closing_days}
|
||||||
|
error={!!offerErrors.closing_days}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Other Contingencies (e.g., inspection, financing)"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={contingencies}
|
||||||
|
onChange={(e) => setContingencies(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={offerErrors.contingencies}
|
||||||
|
error={!!offerErrors.contingencies}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Estimated Mortgage Payment Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 5 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Estimated Mortgage Payment
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Down Payment (%)"
|
||||||
|
type="number"
|
||||||
|
value={downPaymentPercentage}
|
||||||
|
onChange={(e) => setDownPaymentPercentage(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Annual Interest Rate (%)"
|
||||||
|
type="number"
|
||||||
|
value={loanInterestRate}
|
||||||
|
onChange={(e) => setLoanInterestRate(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Loan Term (Years)"
|
||||||
|
type="number"
|
||||||
|
value={loanTermYears}
|
||||||
|
onChange={(e) => setLoanTermYears(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
p: 2,
|
||||||
|
border: '1px dashed #bdbdbd',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1">Estimated Monthly Payment (P&I):</Typography>
|
||||||
|
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{estimatedMonthlyPayment}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
*This is an estimate for Principal & Interest only. It does not include taxes,
|
||||||
|
insurance, or HOA fees.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default OfferDialogContent;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import React, { ReactElement, useState, useRef } from 'react';
|
||||||
|
import OfferDialogContent, { OfferDialogContentHandle, OfferData } from './OfferDialogContent'; // Import the handle and data types
|
||||||
|
import SellerDisclousureDialogContent, {
|
||||||
|
SellerDisclousureDialogContentHandle,
|
||||||
|
SellerDisclousureData,
|
||||||
|
} from './SellerDisclousureDialogContent';
|
||||||
|
import HomeImprovementReciptDialogContent, {
|
||||||
|
HomeImprovementReceiptData,
|
||||||
|
HomeImprovementReceiptDialogContentHandle,
|
||||||
|
} from './HomeImprovementReciptDialogContent';
|
||||||
|
import { axiosInstance } from '../../../../../../axiosApi';
|
||||||
|
// Assuming BidAPI, PropertiesAPI, VendorAPI are defined in types or available
|
||||||
|
|
||||||
|
export type PropertyOwnerDocumentType = 'offer' | 'seller_disclosure' | 'home_improvement_receipt';
|
||||||
|
|
||||||
|
// Custom message box component for user feedback
|
||||||
|
const MessageBox = ({ message, onClose }: { message: string; onClose: () => void }) => (
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
<DialogTitle>Error</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>{message}</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>OK</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PropertyOwnerDocumentDialog = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
properties,
|
||||||
|
bids,
|
||||||
|
}: DocumentDialogProps): ReactElement => {
|
||||||
|
const [documentType, setDocumentType] = useState<PropertyOwnerDocumentType | null>(null);
|
||||||
|
const offerDialogRef = useRef<OfferDialogContentHandle>(null); // Ref for OfferDialogContent
|
||||||
|
const sellerDisclosureRef = useRef<SellerDisclousureDialogContentHandle>(null);
|
||||||
|
const homeImprovementRef = useRef<HomeImprovementReceiptDialogContentHandle>(null);
|
||||||
|
const [submittedOfferData, setSubmittedOfferData] = useState<OfferData | null>(null); // State to hold submitted data
|
||||||
|
const [showError, setShowError] = useState<boolean>(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const [homeImprovmentErrors, setHomeImprovementErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const [sellerDisclosureErrors, setSellerDisclosureErrors] = useState<{ [key: string]: string }>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [offerErrors, setOfferErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// Callback function to receive data from OfferDialogContent
|
||||||
|
const handleOfferSubmit = async (
|
||||||
|
docType: PropertyOwnerDocumentType,
|
||||||
|
data: OfferData | SellerDisclousureData | HomeImprovementReceiptData,
|
||||||
|
) => {
|
||||||
|
if (docType === 'offer') {
|
||||||
|
console.log(data);
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/documents/upload/', {
|
||||||
|
document_type: docType,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setOfferErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.response.data.errors);
|
||||||
|
setOfferErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
} else if (docType === 'seller_disclosure') {
|
||||||
|
console.log(data);
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/documents/upload/', {
|
||||||
|
document_type: docType,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
if (response.error) {
|
||||||
|
setSellerDisclosureErrors(response.error);
|
||||||
|
}
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.response.data.errors);
|
||||||
|
setSellerDisclosureErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SellerDisclousureData Data Received in PropertyOwnerDocumentDialog:', data);
|
||||||
|
} else if (docType === 'home_improvement_receipt') {
|
||||||
|
console.log('HomeImprovementReceiptData Data Received in PropertyOwnerDocumentDialog:', data);
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here you would typically send this 'data' to your backend API
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to render the appropriate dialog content based on document type
|
||||||
|
const getDialogContent = (document_type: PropertyOwnerDocumentType | null): ReactElement => {
|
||||||
|
if (document_type === 'offer') {
|
||||||
|
return (
|
||||||
|
<OfferDialogContent
|
||||||
|
ref={offerDialogRef} // Pass the ref to the OfferDialogContent
|
||||||
|
onSubmit={handleOfferSubmit} // Pass the callback for submitted data
|
||||||
|
offerErrors={offerErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (document_type === 'seller_disclosure') {
|
||||||
|
// If SellerDisclousureDialogContent also needs to pass data, it would need
|
||||||
|
// a similar ref and onSubmit prop setup.
|
||||||
|
return (
|
||||||
|
<SellerDisclousureDialogContent
|
||||||
|
ref={sellerDisclosureRef}
|
||||||
|
onSubmit={handleOfferSubmit}
|
||||||
|
properties={properties} // Pass properties down
|
||||||
|
sellerDisclosureErrors={sellerDisclosureErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (document_type === 'home_improvement_receipt') {
|
||||||
|
// Similar to offer, if HomeImprovementReciptDialogContent needs to pass data.
|
||||||
|
return (
|
||||||
|
<HomeImprovementReciptDialogContent
|
||||||
|
ref={homeImprovementRef} // Example: needs its own ref
|
||||||
|
onSubmit={handleOfferSubmit} // Example: needs its own handler
|
||||||
|
properties={properties}
|
||||||
|
vendors={[]}
|
||||||
|
bids={bids}
|
||||||
|
homeImprovmentErrors={homeImprovmentErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Typography>Please select a document type</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the currently active dialog content as a React element
|
||||||
|
const dialogContentElement = getDialogContent(documentType);
|
||||||
|
|
||||||
|
// Function to handle the "Create" button click in the parent dialog
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (documentType === 'offer') {
|
||||||
|
if (offerDialogRef.current) {
|
||||||
|
// Trigger the submitForm method exposed by OfferDialogContent via the ref
|
||||||
|
offerDialogRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Offer dialog content not ready. Please select a document type.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else if (documentType === 'seller_disclosure') {
|
||||||
|
if (sellerDisclosureRef.current) {
|
||||||
|
sellerDisclosureRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Seller Disclosure submission not implemented yet.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else if (documentType === 'contractor_recipt') {
|
||||||
|
// Placeholder for home improvement receipt submission
|
||||||
|
if (homeImprovementRef.current) {
|
||||||
|
homeImprovementRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Home Improvement Recipt submission not implemented yet.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no document type is selected
|
||||||
|
setErrorMessage('Please select a document type before creating.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<DialogTitle>Create a new document</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Select
|
||||||
|
value={documentType || ''} // Control the value of the Select component
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value === 'offer' ||
|
||||||
|
e.target.value === 'seller_disclosure' ||
|
||||||
|
e.target.value === 'contractor_recipt'
|
||||||
|
) {
|
||||||
|
setDocumentType(e.target.value as PropertyOwnerDocumentType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
displayEmpty // Allows the placeholder to be displayed when value is empty
|
||||||
|
sx={{ mb: 2, minWidth: 200 }} // Add some margin-bottom for spacing
|
||||||
|
>
|
||||||
|
<MenuItem value="" disabled>
|
||||||
|
Select Document Type
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="offer">Offer</MenuItem>
|
||||||
|
<MenuItem value="seller_disclosure">Seller Disclosure</MenuItem>
|
||||||
|
<MenuItem value="contractor_recipt">Home Improvement Recipt</MenuItem>
|
||||||
|
</Select>
|
||||||
|
{dialogContentElement} {/* Render the selected dialog content */}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="primary" sx={{ ml: 'auto' }} onClick={handleCreate}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
{/* Render the message box if there's an error */}
|
||||||
|
{showError && <MessageBox message={errorMessage} onClose={() => setShowError(false)} />}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyOwnerDocumentDialog;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement, forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
export interface SellerDisclousureData {
|
||||||
|
generalDefects: string;
|
||||||
|
roofCondition: string;
|
||||||
|
roofAge: string;
|
||||||
|
knownRoofLeaks: boolean;
|
||||||
|
plumbingIssues: string;
|
||||||
|
electricalIssues: string;
|
||||||
|
hvacCondition: string;
|
||||||
|
hvacAge: string;
|
||||||
|
knownLeadPaint: boolean;
|
||||||
|
knownAsbestos: boolean;
|
||||||
|
knownRadon: boolean;
|
||||||
|
pastWaterDamage: string;
|
||||||
|
structuralIssues: string;
|
||||||
|
neighborhoodNuisances: string;
|
||||||
|
propertyLineDisputes: string;
|
||||||
|
appliancesIncluded: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the handle for the ref that the parent can use to interact with this component
|
||||||
|
export interface SellerDisclousureDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for props of OfferDialogContent
|
||||||
|
interface SellerDisclousureFormDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, disclosure: SellerDisclousureData) => void; // Callback function to send data to parent
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
sellerDisclosureErrors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerDisclousureDialogContent = forwardRef<
|
||||||
|
SellerDisclousureDialogContentHandle,
|
||||||
|
SellerDisclousureFormDialogProps
|
||||||
|
>(({ onSubmit, properties, sellerDisclosureErrors }, ref) => {
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
|
||||||
|
const [generalDefects, setGeneralDefects] = useState<string>('');
|
||||||
|
const [roofCondition, setRoofCondition] = useState<string>('');
|
||||||
|
const [roofAge, setRoofAge] = useState<string>('');
|
||||||
|
const [knownRoofLeaks, setKnownRoofLeaks] = useState<boolean>(false);
|
||||||
|
const [plumbingIssues, setPlumbingIssues] = useState<string>('');
|
||||||
|
const [electricalIssues, setElectricalIssues] = useState<string>('');
|
||||||
|
const [hvacCondition, setHvacCondition] = useState<string>('');
|
||||||
|
const [hvacAge, setHvacAge] = useState<string>('');
|
||||||
|
const [knownLeadPaint, setKnownLeadPaint] = useState<boolean>(false);
|
||||||
|
const [knownAsbestos, setKnownAsbestos] = useState<boolean>(false);
|
||||||
|
const [knownRadon, setKnownRadon] = useState<boolean>(false);
|
||||||
|
const [pastWaterDamage, setPastWaterDamage] = useState<string>('');
|
||||||
|
const [structuralIssues, setStructuralIssues] = useState<string>('');
|
||||||
|
const [neighborhoodNuisances, setNeighborhoodNuisances] = useState<string>('');
|
||||||
|
const [propertyLineDisputes, setPropertyLineDisputes] = useState<string>('');
|
||||||
|
const [appliancesIncluded, setAppliancesIncluded] = useState<string>('');
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
const formData: SellerDisclousureData = {
|
||||||
|
property: selectedProperty?.id,
|
||||||
|
general_defects: generalDefects,
|
||||||
|
roof_condition: roofCondition,
|
||||||
|
roof_age: roofAge,
|
||||||
|
known_roof_leaks: knownRoofLeaks,
|
||||||
|
plumbing_issues: plumbingIssues,
|
||||||
|
electrical_issues: electricalIssues,
|
||||||
|
hvac_condition: hvacCondition,
|
||||||
|
hvac_age: hvacAge,
|
||||||
|
known_lead_paint: knownLeadPaint,
|
||||||
|
known_asbestos: knownAsbestos,
|
||||||
|
known_radon: knownRadon,
|
||||||
|
past_water_damage: pastWaterDamage,
|
||||||
|
structural_issues: structuralIssues,
|
||||||
|
neighborhood_nuisances: neighborhoodNuisances,
|
||||||
|
property_line_disputes: propertyLineDisputes,
|
||||||
|
appliances_included: appliancesIncluded,
|
||||||
|
};
|
||||||
|
onSubmit('seller_disclosure', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(sellerDisclosureErrors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={selectedProperty?.id || ''}
|
||||||
|
label="Property"
|
||||||
|
onChange={(e) => {
|
||||||
|
const foundProperty = properties.find(
|
||||||
|
(property) => property.id === Number(e.target.value),
|
||||||
|
);
|
||||||
|
setSelectedProperty(foundProperty);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<MenuItem value={property.id}>{property.address}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{selectedProperty && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
|
||||||
|
{selectedProperty.state} {selectedProperty.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
Please disclose any known material defects or information about the property. Honesty is
|
||||||
|
crucial to avoid potential legal issues.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="General Known Defects/Issues"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={generalDefects}
|
||||||
|
onChange={(e) => setGeneralDefects(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={`Describe any general issues not covered elsewhere (e.g., drainage problems, pest infestations). ${sellerDisclosureErrors.general_defects}`}
|
||||||
|
error={!!sellerDisclosureErrors.general_defects}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Property Systems Section */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Roof Information
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Roof Condition"
|
||||||
|
value={roofCondition}
|
||||||
|
onChange={(e) => setRoofCondition(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.roof_condition}
|
||||||
|
helperText={sellerDisclosureErrors.roof_condition}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Approximate Roof Age (Years)"
|
||||||
|
type="number"
|
||||||
|
value={roofAge}
|
||||||
|
onChange={(e) => setRoofAge(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.roof_age}
|
||||||
|
helperText={sellerDisclosureErrors.roof_age}
|
||||||
|
/>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_roof_leaks}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownRoofLeaks}
|
||||||
|
onChange={(e) => setKnownRoofLeaks(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Past or Present Roof Leaks?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_roof_leaks}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Major Systems
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Plumbing System Issues"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={plumbingIssues}
|
||||||
|
onChange={(e) => setPlumbingIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.plumbing_issues}
|
||||||
|
helperText={sellerDisclosureErrors.plumbing_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Electrical System Issues"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={electricalIssues}
|
||||||
|
onChange={(e) => setElectricalIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.electrical_issues}
|
||||||
|
helperText={sellerDisclosureErrors.electrical_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="HVAC (Heating, Ventilation, Air Conditioning) Condition"
|
||||||
|
value={hvacCondition}
|
||||||
|
onChange={(e) => setHvacCondition(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.hvac_condition}
|
||||||
|
helperText={sellerDisclosureErrors.hvac_condition}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Approximate HVAC Age (Years)"
|
||||||
|
type="number"
|
||||||
|
value={hvacAge}
|
||||||
|
onChange={(e) => setHvacAge(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.hvac_age}
|
||||||
|
helperText={sellerDisclosureErrors.hvac_age}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Environmental & Other Issues */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Environmental & Other Issues
|
||||||
|
</Typography>
|
||||||
|
<FormGroup row>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_lead_paint}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownLeadPaint}
|
||||||
|
onChange={(e) => setKnownLeadPaint(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Lead-Based Paint?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_lead_paint}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_asbestos}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownAsbestos}
|
||||||
|
onChange={(e) => setKnownAsbestos(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Asbestos?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_asbestos}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_radon}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownRadon}
|
||||||
|
onChange={(e) => setKnownRadon(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Radon?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_radon}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
<TextField
|
||||||
|
label="Past or Present Water Damage"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={pastWaterDamage}
|
||||||
|
onChange={(e) => setPastWaterDamage(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={`Describe location, cause, and remediation if applicable. ${sellerDisclosureErrors.past_water_damage}`}
|
||||||
|
error={!!sellerDisclosureErrors.past_water_damage}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Structural Issues (e.g., foundation, walls)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={structuralIssues}
|
||||||
|
onChange={(e) => setStructuralIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.structural_issues}
|
||||||
|
helperText={sellerDisclosureErrors.structural_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Neighborhood Nuisances (e.g., excessive noise, odors)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={neighborhoodNuisances}
|
||||||
|
onChange={(e) => setNeighborhoodNuisances(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.neighborhood_nuisances}
|
||||||
|
helperText={sellerDisclosureErrors.neighborhood_nuisances}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Property Line Disputes (Past or Present)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={propertyLineDisputes}
|
||||||
|
onChange={(e) => setPropertyLineDisputes(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.past_water_damage}
|
||||||
|
helperText={sellerDisclosureErrors.past_water_damage}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Appliances Included */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Appliances Included in Sale
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="List all appliances included"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={appliancesIncluded}
|
||||||
|
onChange={(e) => setAppliancesIncluded(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
placeholder="e.g., Refrigerator, Washer, Dryer, Dishwasher..."
|
||||||
|
error={!!sellerDisclosureErrors.appliances_included}
|
||||||
|
helperText={sellerDisclosureErrors.appliances_included}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{ mt: 3, p: 2, border: '1px dashed #bdbdbd', borderRadius: 1, bgcolor: '#fffde7' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
By submitting this form, you acknowledge that the information provided is accurate to
|
||||||
|
the best of your knowledge and belief.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SellerDisclousureDialogContent;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Dialog, Typography } from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
const VendorDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<Typography>Show the Vendor dialog</Typography>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDocumentDialog;
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// src/components/DocumentManager.tsx
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Container,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { BidAPI, DocumentAPI, PropertiesAPI } from 'types';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
|
|
||||||
|
import ArticleIcon from '@mui/icons-material/Article';
|
||||||
|
import { formatTimestamp } from 'utils';
|
||||||
|
import AddDocumentDialog from './AddDocumentDialog';
|
||||||
|
import SellerDisclosureDisplay from './SellerDisclosureDisplay';
|
||||||
|
import { PropertyOwnerDocumentType } from './Dialog/PropertyOwnerDocumentDialog';
|
||||||
|
import HomeImprovementReceiptDisplay from './HomeImprovementReceiptDisplay';
|
||||||
|
import OfferDisplay from './OfferDisplay';
|
||||||
|
import OfferNegotiationHistory from './OfferNegotiationHistory';
|
||||||
|
|
||||||
|
interface DocumentManagerProps {}
|
||||||
|
|
||||||
|
const getDocumentTitle = (docType: PropertyOwnerDocumentType) => {
|
||||||
|
if (docType === 'seller_disclosure') {
|
||||||
|
return 'Seller Disclosure';
|
||||||
|
} else if (docType === 'offer_letter') {
|
||||||
|
return 'Offer';
|
||||||
|
} else if (docType === 'home_improvement_receipt') {
|
||||||
|
return 'Home Improvement Receipt';
|
||||||
|
} else {
|
||||||
|
return docType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMyTypeDocument = (upload_by: number, account_id: number, document_type: string) => {
|
||||||
|
console.log(upload_by, account_id, document_type);
|
||||||
|
if (document_type === 'offer_letter') {
|
||||||
|
return !(upload_by === account_id);
|
||||||
|
} else if (document_type === 'seller_disclosure') {
|
||||||
|
return upload_by === account_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentManager: React.FC<DocumentManagerProps> = ({}) => {
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
|
||||||
|
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [bids, setBids] = useState<BidAPI[]>([]);
|
||||||
|
const [selectedDocument, setSelectedDocument] = useState<DocumentAPI | null>(null);
|
||||||
|
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||||
|
const [selectedPropertyForDocument, setSelectedPropertyForDocument] =
|
||||||
|
useState<PropertiesAPI | null>(null);
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocuments = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch documents:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchProperties = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||||
|
setProperties(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchBids = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||||
|
setBids(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDocuments();
|
||||||
|
fetchProperties();
|
||||||
|
fetchBids();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedDocumentId = searchParams.get('selectedDocument');
|
||||||
|
if (selectedDocumentId) {
|
||||||
|
fetchDocument(parseInt(selectedDocumentId, 10));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProperty = async () => {
|
||||||
|
console.log(account.id);
|
||||||
|
console.log(selectedDocument?.uploaded_by);
|
||||||
|
console.log(
|
||||||
|
isMyTypeDocument(selectedDocument?.uploaded_by, account.id, selectedDocument.document_type),
|
||||||
|
);
|
||||||
|
const url = isMyTypeDocument(
|
||||||
|
selectedDocument?.uploaded_by,
|
||||||
|
account.id,
|
||||||
|
selectedDocument.document_type,
|
||||||
|
)
|
||||||
|
? `/properties/${selectedDocument.property}/`
|
||||||
|
: `/properties/${selectedDocument.property}/?search=1`;
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||||
|
setSelectedPropertyForDocument(data);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const other_url =
|
||||||
|
url === `/properties/${selectedDocument.property}/`
|
||||||
|
? `/properties/${selectedDocument.property}/?search=1`
|
||||||
|
: `/properties/${selectedDocument.property}/`;
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(other_url);
|
||||||
|
setSelectedPropertyForDocument(data);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProperty();
|
||||||
|
}, [selectedDocument]);
|
||||||
|
|
||||||
|
console.log(documents);
|
||||||
|
|
||||||
|
const fetchDocument = async (documentId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`);
|
||||||
|
if (response?.data) {
|
||||||
|
setSelectedDocument(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSelectedDocument(null);
|
||||||
|
console.error('Failed to fetch document:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentPaneComponent = (selectedDocument: DocumentAPI) => {
|
||||||
|
console.log(selectedDocument?.document_type);
|
||||||
|
if (!selectedDocument) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 3,
|
||||||
|
color: 'grey.500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArticleIcon sx={{ fontSize: 80, mb: 2 }} />
|
||||||
|
<Typography variant="h6">Select a document to view</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Click on a document from the left panel to get started.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'seller_disclosure') {
|
||||||
|
console.log(selectedDocument);
|
||||||
|
return (
|
||||||
|
<SellerDisclosureDisplay
|
||||||
|
disclosureData={selectedDocument.sub_document}
|
||||||
|
property={selectedPropertyForDocument}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'home_improvement_receipt') {
|
||||||
|
return (
|
||||||
|
<HomeImprovementReceiptDisplay
|
||||||
|
receiptData={selectedDocument.sub_document}
|
||||||
|
property={selectedDocument.property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'offer_letter') {
|
||||||
|
return (
|
||||||
|
<OfferNegotiationHistory
|
||||||
|
property={selectedPropertyForDocument}
|
||||||
|
isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
|
||||||
|
offerData={selectedDocument}
|
||||||
|
/>
|
||||||
|
// <OfferDisplay
|
||||||
|
// offerData={selectedDocument.sub_document}
|
||||||
|
// property={selectedPropertyForDocument}
|
||||||
|
// isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
|
||||||
|
// documentId={selectedDocument.id}
|
||||||
|
// />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Typography>Not sure what this is</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accountLoading) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
} else if (!account) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="lg"
|
||||||
|
sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Grid container sx={{ height: '100%' }}>
|
||||||
|
{/* Left Panel: Document List */}
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, md: 4 }}
|
||||||
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
|
||||||
|
<Stack direction="row" sx={{ width: '100%' }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Documents
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 'auto', mb: 0 }}
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||||
|
<ArticleIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||||
|
<Typography>There are no documents yet.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
documents
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||||
|
)
|
||||||
|
.map((document) => (
|
||||||
|
<ListItem
|
||||||
|
key={document.id}
|
||||||
|
button
|
||||||
|
selected={selectedDocument?.id === document.id}
|
||||||
|
onClick={() => fetchDocument(document.id)}
|
||||||
|
sx={{ py: 1.5, px: 2 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{getDocumentTitle(document.document_type)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ flexGrow: 1, pr: 1 }}
|
||||||
|
>
|
||||||
|
{document.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{formatTimestamp(document.updated_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
{/* Right Panel: Offer Detail */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{getDocumentPaneComponent(selectedDocument)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<AddDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
account={account}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentManager;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Grid,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
};
|
||||||
|
business_name: string;
|
||||||
|
business_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptData {
|
||||||
|
propertyId: number;
|
||||||
|
vendor: Vendor; // Assuming the full vendor object is passed for display
|
||||||
|
dateOfWork: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
// receiptFile?: string; // Assume URL string for display
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptDisplayProps {
|
||||||
|
receiptData: HomeImprovementReceiptData;
|
||||||
|
property: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomeImprovementReceiptDisplay: React.FC<HomeImprovementReceiptDisplayProps> = ({
|
||||||
|
receiptData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
if (!receiptData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No receipt information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vendor, dateOfWork, description, cost } = receiptData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Home Improvement Receipt
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Vendor" secondary={vendor.business_name} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Vendor Type" secondary={vendor.business_type} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Date of Work"
|
||||||
|
secondary={dateOfWork ? new Date(dateOfWork).toLocaleDateString() : 'N/A'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Cost" secondary={formatCurrency(cost)} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Description of Work Performed
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{description || 'No description provided.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeImprovementReceiptDisplay;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
CardActions,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferData {
|
||||||
|
propertyId: number;
|
||||||
|
offer_price: number;
|
||||||
|
closing_date?: string;
|
||||||
|
closing_days?: number;
|
||||||
|
contingencies: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferDisplayProps {
|
||||||
|
offerData: OfferData;
|
||||||
|
property: Property;
|
||||||
|
isPropertyOwner: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
onCounter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OfferDisplay: React.FC<OfferDisplayProps> = ({
|
||||||
|
offerData,
|
||||||
|
property,
|
||||||
|
isPropertyOwner,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
onCounter,
|
||||||
|
}) => {
|
||||||
|
if (!offerData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No offer information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { offer_price, closing_date, closing_days, contingencies, status } = offerData;
|
||||||
|
|
||||||
|
const getClosingText = () => {
|
||||||
|
if (closing_days) {
|
||||||
|
return `${closing_days} days`;
|
||||||
|
}
|
||||||
|
if (closing_date) {
|
||||||
|
try {
|
||||||
|
const formattedDate = new Date(closing_date).toLocaleDateString();
|
||||||
|
return `By ${formattedDate}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid closingDate format:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Not specified';
|
||||||
|
};
|
||||||
|
|
||||||
|
const showActions =
|
||||||
|
(isPropertyOwner && (status === 'submitted' || status === 'pending')) ||
|
||||||
|
(!isPropertyOwner && status === 'countered');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Residential House Offer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.primary">
|
||||||
|
Status: {status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Offer Price" secondary={formatCurrency(offer_price)} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Closing Timeline" secondary={getClosingText()} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Other Contingencies
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{contingencies || 'No contingencies listed.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
{showActions && (
|
||||||
|
<CardActions>
|
||||||
|
<Button variant="contained" onClick={onAccept}>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCounter}>Counter</Button>
|
||||||
|
<Button onClick={onReject}>Reject</Button>
|
||||||
|
</CardActions>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OfferDisplay;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import OfferDisplay, { OfferData } from './OfferDisplay';
|
||||||
|
import { DocumentAPI } from 'types';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferNegotiationHistoryProps {
|
||||||
|
property: Property;
|
||||||
|
isPropertyOwner: boolean;
|
||||||
|
offerData: DocumentAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OfferNegotiationHistory: React.FC<OfferNegotiationHistoryProps> = ({
|
||||||
|
property,
|
||||||
|
isPropertyOwner,
|
||||||
|
offerData,
|
||||||
|
}) => {
|
||||||
|
const [offer, setOffer] = useState<OfferData>(offerData);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedAccordion, setExpandedAccordion] = useState<number | false>(false);
|
||||||
|
|
||||||
|
const [history, setHistory] = useState<OfferData[]>([]);
|
||||||
|
// This useEffect builds the negotiation history
|
||||||
|
useEffect(() => {
|
||||||
|
let currentOffer = offerData.sub_document;
|
||||||
|
const negotiationHistory: OfferData[] = [];
|
||||||
|
|
||||||
|
// Traverse the parent chain until there is no parent
|
||||||
|
while (currentOffer) {
|
||||||
|
negotiationHistory.push(currentOffer);
|
||||||
|
currentOffer = currentOffer.parent_offer;
|
||||||
|
}
|
||||||
|
console.log(negotiationHistory);
|
||||||
|
const reversedHistory = negotiationHistory.reverse();
|
||||||
|
|
||||||
|
setHistory(reversedHistory);
|
||||||
|
if (reversedHistory.length > 0) {
|
||||||
|
setExpandedAccordion(reversedHistory[reversedHistory.length - 1].id);
|
||||||
|
}
|
||||||
|
}, [offerData]);
|
||||||
|
|
||||||
|
const handleChange = (panelId: number) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||||
|
console.log(panelId, isExpanded);
|
||||||
|
setExpandedAccordion(isExpanded ? panelId : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// const fetchOffers = async () => {
|
||||||
|
// setLoading(true);
|
||||||
|
// setError(null);
|
||||||
|
// try {
|
||||||
|
// // Assuming a new endpoint or a modified one that returns all offers for a property.
|
||||||
|
// // You may need to create this in your Django views.
|
||||||
|
// const response = await axiosInstance.get(`/documents/retrieve/?docId=${property.id}/`);
|
||||||
|
// setOffers(response.data);
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error('Failed to fetch offers:', err);
|
||||||
|
// setError('Failed to load offers. Please try again later.');
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// console.log(property);
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (property) {
|
||||||
|
// fetchOffers();
|
||||||
|
// }
|
||||||
|
// }, [property]);
|
||||||
|
|
||||||
|
const handleOfferAction = async (
|
||||||
|
documentId: number,
|
||||||
|
action: 'accept' | 'reject' | 'counter',
|
||||||
|
newOfferData?: any,
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
document_id: documentId,
|
||||||
|
action: action,
|
||||||
|
...newOfferData, // Pass new offer data for a counter-offer
|
||||||
|
};
|
||||||
|
await axiosInstance.patch('/documents/retrieve/', payload);
|
||||||
|
//await fetchOffers(); // Re-fetch offers to see the updated status
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to perform action '${action}':`, err);
|
||||||
|
setError('Failed to update offer status. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (offers.length === 0) {
|
||||||
|
// return (
|
||||||
|
// <Box sx={{ p: 3 }}>
|
||||||
|
// <Typography variant="h6" color="text.secondary">
|
||||||
|
// No offers have been submitted for this property.
|
||||||
|
// </Typography>
|
||||||
|
// </Box>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ my: 4 }}>
|
||||||
|
<Typography variant="h4" component="h2" gutterBottom>
|
||||||
|
Negotiation History
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{history.length > 0 ? (
|
||||||
|
history.map((offer, index) => (
|
||||||
|
<Accordion
|
||||||
|
key={index}
|
||||||
|
expanded={expandedAccordion === index}
|
||||||
|
onChange={handleChange(index)}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography>
|
||||||
|
Offer - {offer.status.toUpperCase()} - ${offer.offer_price}
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<OfferDisplay
|
||||||
|
offerData={offer}
|
||||||
|
property={property}
|
||||||
|
isPropertyOwner={isPropertyOwner}
|
||||||
|
onAccept={() => handleOfferAction(offer.id, 'accept')}
|
||||||
|
onReject={() => handleOfferAction(offer.id, 'reject')}
|
||||||
|
onCounter={() => {
|
||||||
|
const newPrice = offer.offer_price * 0.95;
|
||||||
|
handleOfferAction(offer.id, 'counter', { offer_price: newPrice });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography>No negotiation history available.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OfferNegotiationHistory;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Reuse the interfaces from the SellerDisclosureDialog for consistency
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SellerDisclosureData {
|
||||||
|
propertyId: number;
|
||||||
|
general_defects: string;
|
||||||
|
roof_condition: string;
|
||||||
|
roof_age: number | null;
|
||||||
|
known_roof_leaks: boolean;
|
||||||
|
plumbing_issues: string;
|
||||||
|
electrical_issues: string;
|
||||||
|
hvac_condition: string;
|
||||||
|
hvac_age: number | null;
|
||||||
|
known_lead_paint: boolean;
|
||||||
|
known_asbestos: boolean;
|
||||||
|
known_radon: boolean;
|
||||||
|
past_water_damage: string;
|
||||||
|
structural_issues: string;
|
||||||
|
neighborhood_nuisances: string;
|
||||||
|
property_line_disputes: string;
|
||||||
|
appliances_included: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SellerDisclosureDisplayProps {
|
||||||
|
disclosureData: SellerDisclosureData;
|
||||||
|
property: Property; // The property associated with this disclosure
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerDisclosureDisplay: React.FC<SellerDisclosureDisplayProps> = ({
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
console.log(disclosureData);
|
||||||
|
if (!disclosureData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No disclosure information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format boolean values
|
||||||
|
const formatBoolean = (value: boolean) => (value ? 'Yes' : 'No');
|
||||||
|
|
||||||
|
const {
|
||||||
|
general_defects,
|
||||||
|
roof_condition,
|
||||||
|
roof_age,
|
||||||
|
known_roof_leaks,
|
||||||
|
plumbing_issues,
|
||||||
|
electrical_issues,
|
||||||
|
hvac_condition,
|
||||||
|
hvac_age,
|
||||||
|
known_lead_paint,
|
||||||
|
known_asbestos,
|
||||||
|
known_radon,
|
||||||
|
past_water_damage,
|
||||||
|
structural_issues,
|
||||||
|
neighborhood_nuisances,
|
||||||
|
property_line_disputes,
|
||||||
|
appliances_included,
|
||||||
|
} = disclosureData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Seller's Property Disclosure
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* General Information */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
General Information
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="General Known Defects/Issues"
|
||||||
|
secondary={general_defects || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Appliances Included in Sale"
|
||||||
|
secondary={appliances_included || 'None listed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Property Systems */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Property Systems
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Roof Condition"
|
||||||
|
secondary={roof_condition || 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Approximate Roof Age"
|
||||||
|
secondary={roof_age ? `${roof_age} years` : 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Known Roof Leaks"
|
||||||
|
secondary={formatBoolean(known_roof_leaks)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Plumbing System Issues"
|
||||||
|
secondary={plumbing_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Electrical System Issues"
|
||||||
|
secondary={electrical_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="HVAC Condition"
|
||||||
|
secondary={hvac_condition || 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Approximate HVAC Age"
|
||||||
|
secondary={hvac_age ? `${hvac_age} years` : 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Environmental & Other Issues */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Environmental & Other Issues
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Known Lead-Based Paint"
|
||||||
|
secondary={formatBoolean(known_lead_paint)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Known Asbestos" secondary={formatBoolean(known_asbestos)} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Known Radon" secondary={formatBoolean(known_radon)} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Past or Present Water Damage"
|
||||||
|
secondary={past_water_damage || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Structural Issues"
|
||||||
|
secondary={structural_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Neighborhood Nuisances"
|
||||||
|
secondary={neighborhood_nuisances || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Property Line Disputes"
|
||||||
|
secondary={property_line_disputes || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerDisclosureDisplay;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/components/CategoryGrid.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Grid } from '@mui/material';
|
||||||
|
import CategoryCard from './VideoCategory';
|
||||||
|
import { VideoCategory } from 'pages/Education/Education';
|
||||||
|
|
||||||
|
interface CategoryGridProps {
|
||||||
|
categories: VideoCategory[];
|
||||||
|
onSelectCategory: (categoryName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategory }) => {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={category.name}>
|
||||||
|
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryGrid;
|
||||||
@@ -1,214 +1,160 @@
|
|||||||
|
import { useState, useEffect, ReactElement } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
import { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material';
|
import { VideoProgressAPI } from 'types';
|
||||||
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { renderProgress } from '@mui/x-data-grid-generator';
|
|
||||||
import { GridColDef } from '@mui/x-data-grid';
|
|
||||||
|
|
||||||
type EducationInfoProps = {
|
interface CategoryProgress {
|
||||||
title: string;
|
categoryName: string;
|
||||||
}
|
totalProgress: number;
|
||||||
interface Row {
|
videoCount: number;
|
||||||
id: number;
|
averageProgress: number;
|
||||||
task: string;
|
|
||||||
progress: number; // Value from 0 to 100 for the progress bar
|
|
||||||
}
|
|
||||||
export const EducationInfoCards = () => {
|
|
||||||
return(
|
|
||||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
|
||||||
<EducationInfo title={'Education'} />
|
|
||||||
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
|
interface EducationInfoCardProps {
|
||||||
|
category: string;
|
||||||
const navigate = useNavigate();
|
progress: number;
|
||||||
|
totalVideos: number;
|
||||||
const columns: GridColDef[] = [
|
completedVideos: number;
|
||||||
{
|
}
|
||||||
field: 'id',
|
|
||||||
headerName: 'ID'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'title',
|
|
||||||
headerName: 'Title',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'category',
|
|
||||||
headerName: 'Category',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'progress',
|
|
||||||
headerName: 'Progress',
|
|
||||||
flex: 1,
|
|
||||||
renderCell: (params: GridRenderCellParams<Row, number>) => {
|
|
||||||
const progressValue = params.value; // Access the progress value from the row data
|
|
||||||
|
|
||||||
|
const EducationInfoCard = ({
|
||||||
|
category,
|
||||||
|
progress,
|
||||||
|
totalVideos,
|
||||||
|
completedVideos,
|
||||||
|
}: EducationInfoCardCardProps): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center' }}>
|
<Card sx={{ boxShadow: 4, width: '100%' }}>
|
||||||
<Box sx={{ width: '100%', mr: 1 }}>
|
<CardContent>
|
||||||
<LinearProgress variant="determinate" value={progressValue} />
|
<Stack spacing={1}>
|
||||||
</Box>
|
<Typography variant="h6" component="h2">
|
||||||
<Box sx={{ minWidth: 35 }}>
|
{category}
|
||||||
<Typography variant="body2" color="text.secondary">{`${progressValue}%`}</Typography>
|
</Typography>
|
||||||
</Box>
|
<LinearProgress
|
||||||
</Box>
|
variant="determinate"
|
||||||
);
|
value={progress}
|
||||||
},
|
sx={{ height: 8, borderRadius: 5 }}
|
||||||
},
|
/>
|
||||||
{
|
<Typography variant="body2" color="text.secondary">
|
||||||
field: 'status',
|
{completedVideos} of {totalVideos} videos complete
|
||||||
headerName: 'Status',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "How to Research Comparable Properties Like a Pro",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Understanding Price Per Square Foot in Your Neighborhood",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Psychological Pricing: Why $399,900 Works Better Than $400,000",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "When and How to Adjust Your Asking Price",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Handling Lowball Offers: Strategies That Work",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 90,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
title: "DIY Curb Appeal Upgrades Under $500",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 80,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
title: "Decluttering Secrets for Faster Sales",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 5,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
title: "Professional Photography Tips Using Just Your Smartphone",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 50,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
title: "Deep Cleaning Checklist Before Listing",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 50,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
title: "How to stage a home",
|
|
||||||
category: "",
|
|
||||||
progress: 0,
|
|
||||||
status: "NOT_STARTED",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return(
|
|
||||||
<Card
|
|
||||||
sx={(theme) => ({
|
|
||||||
boxShadow: theme.shadows[4],
|
|
||||||
width: 1,
|
|
||||||
height: 'auto',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
sx={{
|
|
||||||
flex: '1 1 auto',
|
|
||||||
padding: 0,
|
|
||||||
':last-child': {
|
|
||||||
paddingBottom: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
|
||||||
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
bgcolor="background.paper"
|
|
||||||
borderRadius={5}
|
|
||||||
width={1}
|
|
||||||
boxShadow={(theme) => theme.shadows[4]}
|
|
||||||
height={1}
|
|
||||||
>
|
|
||||||
<DataGrid
|
|
||||||
getRowHeight={() => 70}
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
onRowClick={(event) => navigate('lesson')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EducationInfoCards = () => {
|
||||||
|
const [videoProgressData, setVideoProgressData] = useState<VideoProgressAPI[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This is a mock function. Replace with your actual API call.
|
||||||
|
const fetchVideoProgress = async (): Promise<VideoProgressAPI[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get(`/videos/progress/`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchVideoProgress();
|
||||||
|
setVideoProgressData(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch video progress data.');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCategoryProgress = () => {
|
||||||
|
if (!videoProgressData) return {};
|
||||||
|
|
||||||
|
const categories: { [key: string]: CategoryProgress } = {};
|
||||||
|
|
||||||
|
videoProgressData.forEach((item) => {
|
||||||
|
// Access the category name from the nested object
|
||||||
|
const categoryName = item.video.category?.name;
|
||||||
|
if (!categoryName) return; // Skip items without a category name
|
||||||
|
|
||||||
|
if (!categories[categoryName]) {
|
||||||
|
categories[categoryName] = {
|
||||||
|
categoryName: categoryName,
|
||||||
|
totalProgress: 0,
|
||||||
|
videoCount: 0,
|
||||||
|
averageProgress: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
categories[categoryName].totalProgress += item.progress;
|
||||||
|
categories[categoryName].videoCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate average progress for each category
|
||||||
|
for (const key in categories) {
|
||||||
|
const categoryInfo = categories[key];
|
||||||
|
categoryInfo.averageProgress = Math.round(
|
||||||
|
categoryInfo.totalProgress / categoryInfo.videoCount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EducationInfo;
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryProgressData = getCategoryProgress();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Typography>Loading progress...</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Alert severity="error">{error}</Alert>;
|
||||||
|
}
|
||||||
|
if (videoProgressData.length === 0) {
|
||||||
|
return <Typography>There are no videos yet</Typography>;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ sm: 'row' }}
|
||||||
|
justifyContent={{ sm: 'space-between' }}
|
||||||
|
gap={3.75}
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{Object.values(categoryProgressData).map((data, index) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
|
||||||
|
<EducationInfoCard
|
||||||
|
category={data.categoryName}
|
||||||
|
progress={data.averageProgress}
|
||||||
|
totalVideos={data.videoCount}
|
||||||
|
completedVideos={
|
||||||
|
videoProgressData.filter(
|
||||||
|
(item) =>
|
||||||
|
item.video.category?.name === data.categoryName && item.progress === 100,
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,56 @@
|
|||||||
|
// src/components/VideoApp/VideoCategoryCard.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { VideoCategory } from 'types';
|
||||||
|
|
||||||
|
interface VideoCategoryCardProps {
|
||||||
|
category: VideoCategory;
|
||||||
|
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||||
|
return (
|
||||||
|
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography gutterBottom variant="h5" component="div">
|
||||||
|
{category.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{category.description}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: '100%', mt: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
>{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={category.categoryProgress}
|
||||||
|
sx={{ height: 8, borderRadius: 5, mt: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
display="block"
|
||||||
|
align="right"
|
||||||
|
>{`${category.categoryProgress.toFixed(0)}%`}</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
<Box sx={{ p: 2, pt: 0 }}>
|
||||||
|
<Button size="small" onClick={() => onSelectCategory(category.id)}>
|
||||||
|
View Videos
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoCategoryCard;
|
||||||
@@ -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,312 @@
|
|||||||
|
// src/components/VideoApp/VideoPlayer.tsx
|
||||||
|
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { VideoItem, VideoProgressAPI } from 'types';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import PauseIcon from '@mui/icons-material/Pause';
|
||||||
|
import ReplayIcon from '@mui/icons-material/Replay';
|
||||||
|
import FullscreenIcon from '@mui/icons-material/Fullscreen';
|
||||||
|
import ExitFullscreenIcon from '@mui/icons-material/FullscreenExit';
|
||||||
|
|
||||||
|
interface VideoProgress {
|
||||||
|
id?: number; // Optional as it might not exist for new progress entries
|
||||||
|
user?: number; // Assuming user ID is a number
|
||||||
|
video?: number; // Video ID
|
||||||
|
current_time?: number; // Current playback time in seconds
|
||||||
|
completed?: boolean;
|
||||||
|
last_watched?: string; // Optional, set by backend
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
video: VideoItem;
|
||||||
|
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video, updateVideoItem }) => {
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
|
||||||
|
if (!video || accountLoading) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
No video selected
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Function to save progress to the backend
|
||||||
|
const saveProgress = useCallback(
|
||||||
|
async (time: number, completed: boolean = false) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
const progressData: VideoProgress = {
|
||||||
|
progress: Math.round(time),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, try to fetch existing progress
|
||||||
|
const response = await axiosInstance.get(
|
||||||
|
`/videos/progress/?user=${account?.id}&video=${video.id}`,
|
||||||
|
);
|
||||||
|
if (response.data.length > 0) {
|
||||||
|
// If progress exists, update it
|
||||||
|
const existingProgress = response.data[0];
|
||||||
|
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
|
||||||
|
await updateVideoItem(time, completed, progressData.progress, video.id);
|
||||||
|
} else {
|
||||||
|
// If no progress, create a new one
|
||||||
|
await axiosInstance.post('/videos/progress/', progressData);
|
||||||
|
}
|
||||||
|
if (completed) {
|
||||||
|
setSnackbarMessage('Video progress saved: Completed!');
|
||||||
|
} else {
|
||||||
|
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
|
||||||
|
}
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save video progress:', err);
|
||||||
|
setError('Failed to save video progress.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[video.id, account?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch initial progress when video changes or component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProgress = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get<VideoProgressAPI>(
|
||||||
|
`/videos/progress/${video.id}/?user=${account?.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const progress: VideoProgress = {
|
||||||
|
current_time: data.progress,
|
||||||
|
progress: data.progress,
|
||||||
|
};
|
||||||
|
setCurrentTime(progress?.current_time || 0);
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = progress.current_time || 0;
|
||||||
|
}
|
||||||
|
setSnackbarMessage(`Resuming from ${Math.floor(progress?.current_time || 0)}s`);
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
} else {
|
||||||
|
setCurrentTime(0);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch video progress:', err);
|
||||||
|
setError('Failed to load video progress.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProgress();
|
||||||
|
|
||||||
|
// Cleanup on unmount or video change
|
||||||
|
return () => {
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
// Save final progress when component unmounts or video changes
|
||||||
|
if (videoRef.current && !videoRef.current.ended) {
|
||||||
|
saveProgress(videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [video.id, account?.id, saveProgress]);
|
||||||
|
|
||||||
|
// Video event handlers
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newTime = videoRef.current.currentTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
|
||||||
|
// Debounce progress saving to avoid too many API calls
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveProgress(newTime);
|
||||||
|
}, 5000); // Save every 5 seconds of playback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.play().catch((e) => console.error('Error playing video:', e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
saveProgress(videoRef.current.currentTime); // Save immediately on pause
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
saveProgress(video.duration, true); // Mark as completed
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedData = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
// Attempt to play if it was playing before, or if it's the first load
|
||||||
|
if (videoRef.current && currentTime > 0) {
|
||||||
|
videoRef.current.currentTime = currentTime;
|
||||||
|
videoRef.current.play().catch((e) => console.error('Error resuming video:', e));
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoError = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||||
|
console.error('Video error:', event);
|
||||||
|
setError('Error loading video. The file might be missing or corrupted.');
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplay = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = 0;
|
||||||
|
setCurrentTime(0);
|
||||||
|
saveProgress(0, false); // Reset progress
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullScreen = () => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
videoRef.current.requestFullscreen().catch((err) => {
|
||||||
|
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullScreenChange = () => {
|
||||||
|
setIsFullScreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSnackbarClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||||
|
if (reason === 'clickaway') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSnackbarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{video.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{video.description}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={video.videoUrl}
|
||||||
|
controls={false}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
onError={handleVideoError}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography>Your browser does nto support the video tag.</Typography>
|
||||||
|
</video>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Tooltip title={isPlaying ? 'Pause' : 'Play'}>
|
||||||
|
<IconButton onClick={isPlaying ? handlePause : handlePlay} color="primary" size="large">
|
||||||
|
{isPlaying ? <PauseIcon fontSize="inherit" /> : <PlayArrowIcon fontSize="inherit" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Replay">
|
||||||
|
<IconButton onClick={handleReplay} color="primary" size="large">
|
||||||
|
<ReplayIcon fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||||
|
{formatTime(currentTime)} / {formatTime(video.duration)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
|
||||||
|
<IconButton onClick={toggleFullScreen} color="primary" size="large">
|
||||||
|
{isFullScreen ? (
|
||||||
|
<ExitFullscreenIcon fontSize="inherit" />
|
||||||
|
) : (
|
||||||
|
<FullscreenIcon fontSize="inherit" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||||
|
Current Progress: {Math.round((video.progress / video.duration) * 100)}% - Status:{' '}
|
||||||
|
{video.status}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||
@@ -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,93 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { ReactElement, useState, useEffect } from 'react';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { PropertiesAPI, VendorAPI, VendorItem } from 'types';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
type CreateOfferDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
createConversation: () => void;
|
||||||
|
setSelectedVendor: React.Dispatch<React.SetStateAction<VendorItem | null>>;
|
||||||
|
vendors: VendorAPI[];
|
||||||
|
selectedVendor: VendorAPI | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateConversationDialogContent = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
createConversation,
|
||||||
|
setSelectedVendor,
|
||||||
|
vendors,
|
||||||
|
selectedVendor,
|
||||||
|
}: CreateOfferDialogProps): ReactElement => {
|
||||||
|
const [options, setOptions] = useState<string[]>(vendors.map((vendor) => vendor.business_name));
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filteredVendorNames: string[] = vendors.map((vendor) => {
|
||||||
|
return vendor.business_name;
|
||||||
|
});
|
||||||
|
setOptions(filteredVendorNames);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = async (event, newInputValue) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
const inputValue = newInputValue.toLowerCase();
|
||||||
|
const filteredVendors = vendors.filter((vendor) =>
|
||||||
|
vendor.business_name.toLowerCase().includes(inputValue),
|
||||||
|
);
|
||||||
|
const filteredVendorNames: string[] = filteredVendors.map((vendor) => {
|
||||||
|
return vendor.business_name;
|
||||||
|
});
|
||||||
|
setOptions(filteredVendorNames);
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
|
||||||
|
<DialogTitle>Start a new Conversation</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Autocomplete
|
||||||
|
options={options} //vendors}
|
||||||
|
//getOptionLabel={//(option) => option.name}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
const filteredVendors = vendors.filter((vendor) => vendor.business_name === newValue);
|
||||||
|
setSelectedVendor(filteredVendors[0]);
|
||||||
|
}}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the vendor to search for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select a Vendor" variant="outlined" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button onClick={createConversation} color="primary" disabled={!selectedVendor}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateConversationDialogContent;
|
||||||
@@ -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,140 @@
|
|||||||
|
// src/components/PropertyOwnerProfile/AddOpenHouseDialog.tsx
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PropertiesAPI, OpenHouseAPI } from 'types'; // Ensure you have OpenHouseAPI in your types
|
||||||
|
|
||||||
|
import { DatePicker, TimePicker, LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
|
||||||
|
interface AddOpenHouseDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddOpenHouse: (openHouse: Omit<OpenHouseAPI, 'id'> & { listed_date: string }) => void;
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
errors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddOpenHouseDialog: React.FC<AddOpenHouseDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAddOpenHouse,
|
||||||
|
properties,
|
||||||
|
errors,
|
||||||
|
}) => {
|
||||||
|
const [propertyId, setPropertyId] = useState<number | ''>('');
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
const [startTime, setStartTime] = useState<Date | null>(new Date());
|
||||||
|
const [endTime, setEndTime] = useState<Date | null>(new Date());
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date());
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleAddOpenHouse = () => {
|
||||||
|
if (selectedProperty && startTime && endTime && date) {
|
||||||
|
const newOpenHouse = {
|
||||||
|
property: selectedProperty.id,
|
||||||
|
start_time: startTime.toTimeString().split(' ')[0],
|
||||||
|
end_time: endTime.toTimeString().split(' ')[0],
|
||||||
|
listed_date: date.toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddOpenHouse(newOpenHouse);
|
||||||
|
// onClose();
|
||||||
|
// selectedProperty(null);
|
||||||
|
// setStartTime(new Date());
|
||||||
|
// setEndTime(new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(errors);
|
||||||
|
|
||||||
|
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
const filteredPropertiesNames: string[] = properties.map((item) => {
|
||||||
|
return item.address;
|
||||||
|
});
|
||||||
|
setOptions(filteredPropertiesNames);
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose}>
|
||||||
|
<DialogTitle>Add New Open House</DialogTitle> {' '}
|
||||||
|
<DialogContent>
|
||||||
|
{' '}
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
|
{' '}
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
value={selectedProperty?.address || ''}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
const selectedAddr = properties.find((item) => item.address === newValue);
|
||||||
|
setSelectedProperty(selectedAddr || null);
|
||||||
|
}}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the address you want to set an open house for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select Property" fullWidth required margin="normal" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
label="Date"
|
||||||
|
value={date}
|
||||||
|
onChange={(newValue) => setDate(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
helperText={errors.listed_date}
|
||||||
|
error={!!errors.listed_date}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="Start Time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(newValue) => setStartTime(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="End Time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(newValue) => setEndTime(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
{' '}
|
||||||
|
</LocalizationProvider>
|
||||||
|
{' '}
|
||||||
|
</DialogContent>
|
||||||
|
{' '}
|
||||||
|
<DialogActions>
|
||||||
|
{' '}
|
||||||
|
<Button onClick={onClose} color="primary">
|
||||||
|
Cancel {' '}
|
||||||
|
</Button>
|
||||||
|
{' '}
|
||||||
|
<Button
|
||||||
|
onClick={handleAddOpenHouse}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!selectedProperty}
|
||||||
|
>
|
||||||
|
Add {' '}
|
||||||
|
</Button>
|
||||||
|
{' '}
|
||||||
|
</DialogActions>
|
||||||
|
{' '}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddOpenHouseDialog;
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Grid,
|
||||||
|
Autocomplete,
|
||||||
|
CircularProgress,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||||
|
|
||||||
|
import MapComponent from '../../../../base/MapComponent';
|
||||||
|
import {
|
||||||
|
AutocompleteDataResponseAPI,
|
||||||
|
AutocompleteResponseAPI,
|
||||||
|
PropertiesAPI,
|
||||||
|
PropertResponseDataAPI,
|
||||||
|
PropertyResponseAPI,
|
||||||
|
SaleHistoryAPI,
|
||||||
|
SchoolAPI,
|
||||||
|
} from 'types';
|
||||||
|
import { test_property_search } from 'data/mock_property_search';
|
||||||
|
import { extractLatLon } from 'utils';
|
||||||
|
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||||
|
|
||||||
|
interface AddPropertyDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddProperty: (
|
||||||
|
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlacePrediction {
|
||||||
|
description: string;
|
||||||
|
place_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, onAddProperty }) => {
|
||||||
|
const initalValues: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'> = {
|
||||||
|
address: '',
|
||||||
|
street: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip_code: '',
|
||||||
|
market_value: '',
|
||||||
|
loan_amount: '',
|
||||||
|
loan_term: 0,
|
||||||
|
loan_start_date: '',
|
||||||
|
pictures: [],
|
||||||
|
description: '',
|
||||||
|
sq_ft: 0,
|
||||||
|
features: [],
|
||||||
|
num_bedrooms: 0,
|
||||||
|
num_bathrooms: 0,
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
realestate_api_id: 0,
|
||||||
|
views: 0,
|
||||||
|
saves: 0,
|
||||||
|
property_status: 'off_market',
|
||||||
|
schools: [],
|
||||||
|
};
|
||||||
|
const [newProperty, setNewProperty] = useState<
|
||||||
|
Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>
|
||||||
|
>({
|
||||||
|
...initalValues,
|
||||||
|
});
|
||||||
|
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||||
|
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||||
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const [selectedPlace, setSelectedPlace] = useState<PlacePrediction | null>(null);
|
||||||
|
|
||||||
|
// Initialize Google Maps Places Service (requires Google Maps API key loaded globally)
|
||||||
|
// This is a simplified approach. For a more robust solution, use @vis.gl/react-google-maps useMapsLibrary hook
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.google || !window.google.maps || !window.google.maps.places) {
|
||||||
|
console.warn('Google Maps Places API not loaded. Autocomplete will not function.');
|
||||||
|
// You might want to handle this by displaying a message or disabling autocomplete
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setNewProperty((prev) => ({ ...prev, [name]: value }));
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteInputChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
|
if (value.length > 2) {
|
||||||
|
if (test) {
|
||||||
|
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||||
|
// filter the data here
|
||||||
|
} else {
|
||||||
|
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||||
|
'AutoComplete',
|
||||||
|
{
|
||||||
|
search: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setAutocompleteOptions(
|
||||||
|
data.map((item) => ({
|
||||||
|
description: item.address,
|
||||||
|
place_id: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('we need more characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewProperty((prev) => ({ ...prev, address: value }));
|
||||||
|
// if (value.length > 2 && window.google && window.google.maps && window.google.maps.places) {
|
||||||
|
// setAutocompleteLoading(true);
|
||||||
|
// const service = new window.google.maps.places.AutocompleteService();
|
||||||
|
// service.getPlacePredictions({ input: value }, (predictions, status) => {
|
||||||
|
// if (status === window.google.maps.places.PlacesServiceStatus.OK && predictions) {
|
||||||
|
// setAutocompleteOptions(predictions.map(p => ({ description: p.description, place_id: p.place_id })));
|
||||||
|
// } else {
|
||||||
|
// setAutocompleteOptions([]);
|
||||||
|
// }
|
||||||
|
// setAutocompleteLoading(false);
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// setAutocompleteOptions([]);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: PlacePrediction | null,
|
||||||
|
) => {
|
||||||
|
setSelectedPlace(value);
|
||||||
|
console.log('here we go', value);
|
||||||
|
if (value) {
|
||||||
|
console.log('find the test data');
|
||||||
|
const test: boolean = true;
|
||||||
|
if (test) {
|
||||||
|
const parts: string[] =
|
||||||
|
test_property_search.data.currentMortgages[0].recordingDate.split('T');
|
||||||
|
|
||||||
|
// get the features
|
||||||
|
|
||||||
|
// get the schools
|
||||||
|
|
||||||
|
const schools: Omit<SchoolAPI, 'id' | 'created_at' | 'last_updated'>[] =
|
||||||
|
test_property_search.data.schools.map((item) => {
|
||||||
|
const coordinates = extractLatLon(item.location);
|
||||||
|
return {
|
||||||
|
city: item.city,
|
||||||
|
state: item.state,
|
||||||
|
zip_code: item.zip,
|
||||||
|
latitude: coordinates?.latitude,
|
||||||
|
longitude: coordinates?.longitude,
|
||||||
|
school_type: item.type,
|
||||||
|
enrollment: item.enrollment,
|
||||||
|
grades: item.grades,
|
||||||
|
name: item.name,
|
||||||
|
parent_rating: item.parentRating,
|
||||||
|
rating: item.rating,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(schools);
|
||||||
|
|
||||||
|
// get the sale history
|
||||||
|
const sale_history: Omit<SaleHistoryAPI, 'id' | 'created_at' | 'last_updated'>[] =
|
||||||
|
test_property_search.data.saleHistory.map((item) => {
|
||||||
|
return {
|
||||||
|
seq_no: item.seqNo,
|
||||||
|
sale_date: item.saleDate,
|
||||||
|
sale_amount: item.saleAmount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewProperty({
|
||||||
|
address: test_property_search.data.propertyInfo.address.address,
|
||||||
|
street: test_property_search.data.ownerInfo.mailAddress.address,
|
||||||
|
city: test_property_search.data.propertyInfo.address.city,
|
||||||
|
state: test_property_search.data.propertyInfo.address.state,
|
||||||
|
zip_code: test_property_search.data.propertyInfo.address.zip,
|
||||||
|
latitude: test_property_search.data.propertyInfo.latitude,
|
||||||
|
longitude: test_property_search.data.propertyInfo.longitude,
|
||||||
|
market_value: test_property_search.data.estimatedValue.toString(),
|
||||||
|
loan_amount: test_property_search.data.currentMortgages[0].amount.toString(),
|
||||||
|
loan_term: test_property_search.data.currentMortgages[0].term,
|
||||||
|
loan_start_date: parts[0],
|
||||||
|
description: '',
|
||||||
|
features: [],
|
||||||
|
pictures: [],
|
||||||
|
num_bedrooms: test_property_search.data.propertyInfo.bedrooms,
|
||||||
|
num_bathrooms: test_property_search.data.propertyInfo.bathrooms,
|
||||||
|
sq_ft: test_property_search.data.propertyInfo.buildingSquareFeet,
|
||||||
|
realestate_api_id: test_property_search.data.id,
|
||||||
|
views: 0,
|
||||||
|
saves: 0,
|
||||||
|
property_status: 'off_market',
|
||||||
|
schools: schools,
|
||||||
|
tax_info: {
|
||||||
|
assessed_value: test_property_search.data.taxInfo.assessedValue,
|
||||||
|
assessment_year: test_property_search.data.taxInfo.assessmentYear,
|
||||||
|
tax_amount: Number(test_property_search.data.taxInfo.taxAmount),
|
||||||
|
year: test_property_search.data.taxInfo.year,
|
||||||
|
},
|
||||||
|
sale_info: sale_history,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple file upload simulation (you'd replace this with actual file handling and storage)
|
||||||
|
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const filesArray = Array.from(e.target.files);
|
||||||
|
// In a real app, you'd upload these files and get URLs back
|
||||||
|
const imageUrls = filesArray.map((file) => URL.createObjectURL(file)); // For display purposes
|
||||||
|
setNewProperty((prev) => ({ ...prev, pictures: [...prev.pictures, ...imageUrls] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
if (!newProperty.address.trim()) {
|
||||||
|
errors.address = 'Address is required.';
|
||||||
|
}
|
||||||
|
if (!newProperty.city.trim()) {
|
||||||
|
errors.city = 'City is required.';
|
||||||
|
}
|
||||||
|
if (!newProperty.state.trim()) {
|
||||||
|
errors.state = 'State is required.';
|
||||||
|
}
|
||||||
|
if (!newProperty.zip_code.trim()) {
|
||||||
|
errors.zip_code = 'Zip code is required.';
|
||||||
|
}
|
||||||
|
if (newProperty.sq_ft <= 0) {
|
||||||
|
errors.sq_ft = 'Square footage must be greater than 0.';
|
||||||
|
}
|
||||||
|
if (newProperty.num_bedrooms < 0) {
|
||||||
|
errors.num_bedrooms = 'Number of bedrooms cannot be negative.';
|
||||||
|
}
|
||||||
|
if (newProperty.num_bathrooms < 0) {
|
||||||
|
errors.num_bathrooms = 'Number of bathrooms cannot be negative.';
|
||||||
|
}
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
onAddProperty(newProperty);
|
||||||
|
onClose();
|
||||||
|
// Reset form
|
||||||
|
setNewProperty(initalValues);
|
||||||
|
setAutocompleteOptions([]);
|
||||||
|
setSelectedPlace(null);
|
||||||
|
setFormErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAndReset = () => {
|
||||||
|
onClose();
|
||||||
|
setNewProperty(initalValues);
|
||||||
|
setAutocompleteOptions([]);
|
||||||
|
setSelectedPlace(null);
|
||||||
|
setFormErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleCloseAndReset} fullWidth maxWidth="md">
|
||||||
|
<DialogTitle>Add New Property</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={autocompleteOptions}
|
||||||
|
getOptionLabel={(option) => option.description}
|
||||||
|
inputValue={newProperty.address}
|
||||||
|
noOptionsText={'Type at least 3 characters'}
|
||||||
|
onInputChange={handleAddressAutocompleteInputChange}
|
||||||
|
onChange={handleAddressAutocompleteChange}
|
||||||
|
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||||
|
loading={autocompleteLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Address"
|
||||||
|
name="address"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
error={!!formErrors.address}
|
||||||
|
helperText={formErrors.address}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{autocompleteLoading ? (
|
||||||
|
<CircularProgress color="inherit" size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
value={newProperty.city}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.city}
|
||||||
|
helperText={formErrors.city}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={newProperty.state}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.state}
|
||||||
|
helperText={formErrors.state}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zip_code"
|
||||||
|
value={newProperty.zip_code}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.zip_code}
|
||||||
|
helperText={formErrors.zip_code}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={newProperty.description}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Square Footage"
|
||||||
|
name="sq_ft"
|
||||||
|
type="number"
|
||||||
|
value={newProperty.sq_ft || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
error={!!formErrors.sq_ft}
|
||||||
|
helperText={formErrors.sq_ft}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="# Bedrooms"
|
||||||
|
name="num_bedrooms"
|
||||||
|
type="number"
|
||||||
|
value={newProperty.num_bedrooms || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
error={!!formErrors.num_bedrooms}
|
||||||
|
helperText={formErrors.num_bedrooms}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="# Bathrooms"
|
||||||
|
name="num_bathrooms"
|
||||||
|
type="number"
|
||||||
|
value={newProperty.num_bathrooms || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
error={!!formErrors.num_bathrooms}
|
||||||
|
helperText={formErrors.num_bathrooms}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Features (comma-separated)"
|
||||||
|
name="features"
|
||||||
|
value={newProperty.features.join(', ')}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
features: e.target.value.split(',').map((f) => f.trim()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Market Value"
|
||||||
|
name="market_value"
|
||||||
|
value={newProperty.market_value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Amount"
|
||||||
|
name="loan_amount"
|
||||||
|
value={newProperty.loan_amount}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Term (years)"
|
||||||
|
name="loan_term"
|
||||||
|
type="number"
|
||||||
|
value={newProperty.loan_term || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Start Date"
|
||||||
|
name="loan_start_date"
|
||||||
|
type="date"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={newProperty.loan_start_date}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Button variant="contained" component="label">
|
||||||
|
Upload Pictures
|
||||||
|
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{newProperty.pictures.map((url, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={url}
|
||||||
|
alt={`Uploaded ${index}`}
|
||||||
|
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
{newProperty.latitude && newProperty.longitude && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<MapComponent
|
||||||
|
lat={newProperty.latitude}
|
||||||
|
lng={newProperty.longitude}
|
||||||
|
address={newProperty.address}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{Object.keys(formErrors).length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Please correct the errors in the form.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseAndReset}>Cancel</Button>
|
||||||
|
<Button onClick={handleAdd} variant="contained" color="primary">
|
||||||
|
Add Property
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddPropertyDialog;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useEffect, ReactElement } from 'react';
|
||||||
|
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
|
import { AttorneyAPI, UserAPI } from '../../../../../types';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
|
import { ProfileProps } from 'pages/Profile/Profile';
|
||||||
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
|
import AttorneyProfileCard from './AttorneyProfileCard';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
|
||||||
|
const [attorney, setAttorney] = useState<AttorneyAPI | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchAttorneyData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<AttorneyAPI[]> = await axiosInstance.get('/attorney/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setAttorney(data[0]);
|
||||||
|
}
|
||||||
|
// setAttorney(initialAttorneyData);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred while fetching profile data.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttorneyData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpgradeSubscription = () => {
|
||||||
|
if (attorney) {
|
||||||
|
navigate('/upgrade/');
|
||||||
|
//setAttorney((prev) => (prev ? { ...prev, user: { ...prev.user, tier: 'premium' } } : null));
|
||||||
|
//setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||||
|
//setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAttorneyProfile = (updatedAttorney: AttorneyAPI) => {
|
||||||
|
// In a real application, you'd send this data to your backend API
|
||||||
|
try {
|
||||||
|
let newUpdatedAttorney;
|
||||||
|
// if the email is the same, remove it
|
||||||
|
if (updatedAttorney.user.email === account.email) {
|
||||||
|
const { user, ...reducedVendor } = updatedAttorney;
|
||||||
|
const { email, ...reducedUser } = user;
|
||||||
|
newUpdatedAttorney = {
|
||||||
|
user: {
|
||||||
|
profile_created: true,
|
||||||
|
...reducedUser,
|
||||||
|
},
|
||||||
|
...reducedVendor,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newUpdatedAttorney = updatedAttorney;
|
||||||
|
}
|
||||||
|
newUpdatedAttorney.user.profile_created = true;
|
||||||
|
console.log(newUpdatedAttorney);
|
||||||
|
|
||||||
|
const { data, error } = axiosInstance.patch(`/attorney/${account.id}/`, {
|
||||||
|
...newUpdatedAttorney,
|
||||||
|
});
|
||||||
|
console.log(data, error);
|
||||||
|
setAttorney({
|
||||||
|
profile: { profile_created: true, ...updatedAttorney.profile },
|
||||||
|
...updatedAttorney,
|
||||||
|
});
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
// console.log('Saving attorney profile:', updatedAttorney);
|
||||||
|
// setAttorney(updatedAttorney);
|
||||||
|
// setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||||
|
// setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Attorney Profile
|
||||||
|
</Typography>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<AttorneyProfileCard
|
||||||
|
attorney={attorney}
|
||||||
|
onUpgrade={handleUpgradeSubscription}
|
||||||
|
onSave={handleSaveAttorneyProfile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttorneyProfile;
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
Autocomplete,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
|
||||||
|
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||||
|
import MapComponent from 'components/base/MapComponent';
|
||||||
|
import { AttorneyAPI, AutocompleteDataResponseAPI } from 'types';
|
||||||
|
import { PlacePrediction } from './AddPropertyDialog';
|
||||||
|
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||||
|
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||||
|
import { extractLatLon } from 'utils';
|
||||||
|
|
||||||
|
interface AttorneyProfileCardProps {
|
||||||
|
attorney: AttorneyAPI;
|
||||||
|
onUpgrade: () => void; // Assuming attorneys can also upgrade their tier
|
||||||
|
onSave: (updatedAttorney: AttorneyAPI) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||||
|
attorney,
|
||||||
|
onUpgrade,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedAttorney, setEditedAttorney] = useState<AttorneyAPI>(attorney);
|
||||||
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||||
|
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleEditToggle = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditedAttorney(attorney); // Revert on cancel
|
||||||
|
setFormErrors({});
|
||||||
|
}
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditedAttorney((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
// Handle nested user properties if you allow editing them here
|
||||||
|
user:
|
||||||
|
name === 'email' || name === 'first_name' || name === 'last_name'
|
||||||
|
? { ...prev.user, [name]: value }
|
||||||
|
: prev.user,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setEditedAttorney((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: isNaN(numValue) ? '' : numValue, // Allow empty string for numerical input
|
||||||
|
}));
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArrayChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
field: keyof AttorneyAPI,
|
||||||
|
) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEditedAttorney((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item !== ''),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfilePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
const imageUrl = URL.createObjectURL(file);
|
||||||
|
setEditedAttorney((prev) => ({ ...prev, profile_picture: imageUrl }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteInputChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
|
if (value.length > 2) {
|
||||||
|
if (test) {
|
||||||
|
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||||
|
// filter the data here
|
||||||
|
} else {
|
||||||
|
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||||
|
'AutoComplete',
|
||||||
|
{
|
||||||
|
search: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setAutocompleteOptions(
|
||||||
|
data.map((item) => ({
|
||||||
|
description: item.address,
|
||||||
|
place_id: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('we need more characters');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: PlacePrediction | null,
|
||||||
|
) => {
|
||||||
|
if (1) {
|
||||||
|
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
|
||||||
|
if (data.length > 0) {
|
||||||
|
const item = data[0];
|
||||||
|
const coordinates = extractLatLon(item.location);
|
||||||
|
setEditedAttorney((prev) => ({
|
||||||
|
...prev,
|
||||||
|
address: item.address,
|
||||||
|
city: item.city,
|
||||||
|
state: item.state,
|
||||||
|
zip_code: item.zip,
|
||||||
|
latitude: Number(coordinates.latitude),
|
||||||
|
longitude: Number(coordinates.longitude),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// use the api here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(editedAttorney);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
if (!editedAttorney.firm_name.trim()) {
|
||||||
|
errors.firm_name = 'Firm name is required.';
|
||||||
|
}
|
||||||
|
if (!editedAttorney.phone_number.trim()) {
|
||||||
|
errors.phone_number = 'Phone number is required.';
|
||||||
|
} else if (!/^\d{10}$/.test(editedAttorney.phone_number.replace(/\D/g, ''))) {
|
||||||
|
errors.phone_number = 'Invalid phone number format (10 digits).';
|
||||||
|
}
|
||||||
|
if (!editedAttorney.address.trim()) {
|
||||||
|
errors.address = 'Address is required.';
|
||||||
|
}
|
||||||
|
if (!editedAttorney.city.trim()) {
|
||||||
|
errors.city = 'City is required.';
|
||||||
|
}
|
||||||
|
if (!editedAttorney.state.trim()) {
|
||||||
|
errors.state = 'State is required.';
|
||||||
|
}
|
||||||
|
if (!editedAttorney.zip_code.trim()) {
|
||||||
|
errors.zip_code = 'Zip code is required.';
|
||||||
|
}
|
||||||
|
if (editedAttorney.years_experience < 0) {
|
||||||
|
errors.years_experience = 'Years of experience cannot be negative.';
|
||||||
|
}
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
onSave(editedAttorney);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayLat = editedAttorney.latitude ?? 34.0522; // Default to LA for demo
|
||||||
|
const displayLng = editedAttorney.longitude ?? -118.2437; // Default to LA for demo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box display="flex" alignItems="center" gap={2}>
|
||||||
|
<Avatar
|
||||||
|
src={
|
||||||
|
editedAttorney.profile_picture ||
|
||||||
|
'https://via.placeholder.com/150/808080/FFFFFF?text=ATTY'
|
||||||
|
}
|
||||||
|
alt={`${editedAttorney.user.first_name} ${editedAttorney.user.last_name}`}
|
||||||
|
sx={{ width: 80, height: 80 }}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Attorney Profile Information
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary">
|
||||||
|
{editedAttorney.firm_name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{isEditing ? (
|
||||||
|
<Box>
|
||||||
|
<IconButton color="primary" onClick={handleSave}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IconButton color="primary" onClick={handleEditToggle}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="First Name"
|
||||||
|
name="first_name"
|
||||||
|
value={editedAttorney.user.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Last Name"
|
||||||
|
name="last_name"
|
||||||
|
value={editedAttorney.user.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email Address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={editedAttorney.user.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Firm Name"
|
||||||
|
name="firm_name"
|
||||||
|
value={editedAttorney.firm_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.firm_name}
|
||||||
|
helperText={formErrors.firm_name}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Phone Number"
|
||||||
|
name="phone_number"
|
||||||
|
value={editedAttorney.phone_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.phone_number}
|
||||||
|
helperText={formErrors.phone_number}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={autocompleteOptions}
|
||||||
|
getOptionLabel={(option) => option.description}
|
||||||
|
inputValue={editedAttorney.adress}
|
||||||
|
noOptionsText={'Type at least 3 characters'}
|
||||||
|
onInputChange={handleAddressAutocompleteInputChange}
|
||||||
|
onChange={handleAddressAutocompleteChange}
|
||||||
|
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||||
|
loading={autocompleteLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
fullWidth
|
||||||
|
label="Address"
|
||||||
|
name="address"
|
||||||
|
required
|
||||||
|
error={!!formErrors.address}
|
||||||
|
helperText={formErrors.address}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{autocompleteLoading ? (
|
||||||
|
<CircularProgress color="inherit" size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/*<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Address"
|
||||||
|
name="address"
|
||||||
|
value={editedAttorney.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.address}
|
||||||
|
helperText={formErrors.address}
|
||||||
|
/>*/}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
value={editedAttorney.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.city}
|
||||||
|
helperText={formErrors.city}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={editedAttorney.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.state}
|
||||||
|
helperText={formErrors.state}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zip_code"
|
||||||
|
value={editedAttorney.zip_code}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.zip_code}
|
||||||
|
helperText={formErrors.zip_code}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{/*<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Specialties (comma-separated)"
|
||||||
|
name="specialties"
|
||||||
|
value={editedAttorney.specialties.join(', ')}
|
||||||
|
onChange={(e) => handleArrayChange(e, 'specialties')}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{editedAttorney.specialties.map((specialty, index) => (
|
||||||
|
<Chip key={index} label={specialty} size="small" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>*/}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Years of Experience"
|
||||||
|
name="years_experience"
|
||||||
|
type="number"
|
||||||
|
value={editedAttorney.years_experience || ''}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
error={!!formErrors.years_experience}
|
||||||
|
helperText={formErrors.years_experience}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Licensed States (comma-separated)"
|
||||||
|
name="licensed_states"
|
||||||
|
value={editedAttorney.licensed_states.join(', ')}
|
||||||
|
onChange={(e) => handleArrayChange(e, 'licensed_states')}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{editedAttorney.licensed_states.map((state, index) => (
|
||||||
|
<Chip key={index} label={state} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Website URL"
|
||||||
|
name="website"
|
||||||
|
value={editedAttorney.website || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Biography"
|
||||||
|
name="bio"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={editedAttorney.bio || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
component="label"
|
||||||
|
startIcon={<AddPhotoAlternateIcon />}
|
||||||
|
disabled={!isEditing}
|
||||||
|
>
|
||||||
|
Upload Profile Picture
|
||||||
|
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
|
</Typography>
|
||||||
|
{attorney.user.tier === 'basic' && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={onUpgrade}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
disabled={isEditing}
|
||||||
|
>
|
||||||
|
Upgrade to Premium
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{editedAttorney.latitude && editedAttorney.longitude && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Firm Location on Map:
|
||||||
|
</Typography>
|
||||||
|
{/* Assuming MapComponent accepts center, zoom, and a single property for display */}
|
||||||
|
<MapComponent
|
||||||
|
lat={editedAttorney.latitude}
|
||||||
|
lng={editedAttorney.longitude}
|
||||||
|
address={editedAttorney.address}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{Object.keys(formErrors).length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Please correct the errors in the form.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttorneyProfileCard;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
LinearProgress,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { axiosInstance } from 'axiosApi';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
const ChangePasswordCard = () => {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState(0);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const password = e.target.value;
|
||||||
|
setNewPassword(password);
|
||||||
|
const strength = zxcvbn(password).score;
|
||||||
|
setPasswordStrength(strength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordStrength < 2) {
|
||||||
|
setErrors({ newPassword: 'Password is too weak' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axiosInstance.post('/auth/password/change/', {
|
||||||
|
old_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
setMessage({ type: 'success', text: 'Password updated successfully!' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordStrength(0);
|
||||||
|
} catch (error: any) {
|
||||||
|
setMessage({ type: 'error', text: 'Error updating password.' });
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
setErrors(error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrengthColor = () => {
|
||||||
|
switch (passwordStrength) {
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
return 'error';
|
||||||
|
case 2:
|
||||||
|
return 'warning';
|
||||||
|
case 3:
|
||||||
|
case 4:
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Change Password" />
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{message && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity={message.type}>{message.text}</Alert>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
error={!!errors.old_password}
|
||||||
|
helperText={errors.old_password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
error={!!errors.new_password}
|
||||||
|
helperText={errors.new_password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Box sx={{ width: '100%', mt: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(passwordStrength / 4) * 100}
|
||||||
|
color={passwordStrengthColor()}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
{['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][passwordStrength]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
error={!!errors.confirmPassword}
|
||||||
|
helperText={errors.confirmPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button type="submit" variant="contained" color="primary">
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangePasswordCard;
|
||||||
@@ -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,171 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, Typography, TextField, Button, Alert, Tooltip } from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface OfferSubmissionCardProps {
|
||||||
|
onOfferSubmit: (
|
||||||
|
offerAmount: number,
|
||||||
|
closing_days: number,
|
||||||
|
contingencies: string,
|
||||||
|
) => Promise<{ status: number; message?: string }>;
|
||||||
|
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
|
||||||
|
listingPrice: number;
|
||||||
|
existingOffer?: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
||||||
|
onOfferSubmit,
|
||||||
|
listingStatus,
|
||||||
|
listingPrice,
|
||||||
|
existingOffer,
|
||||||
|
}) => {
|
||||||
|
const [offerAmount, setOfferAmount] = useState<string>('');
|
||||||
|
const [closingDuration, setClosingDuration] = useState<string>('');
|
||||||
|
const [contingencies, setContingencies] = useState<string>('None');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getClosingDate = () => {
|
||||||
|
if (closingDuration) {
|
||||||
|
const days = parseInt(closingDuration, 10);
|
||||||
|
if (!isNaN(days)) {
|
||||||
|
const closingDate = new Date();
|
||||||
|
closingDate.setDate(closingDate.getDate() + days);
|
||||||
|
return closingDate.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const offerPercentage =
|
||||||
|
offerAmount && listingPrice ? (parseFloat(offerAmount) / listingPrice) * 100 : 0;
|
||||||
|
|
||||||
|
if (existingOffer) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Offer Already Submitted
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
You have already submitted an offer for this property.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate(`/documents?selectedDocument=${existingOffer.document_id}`)}
|
||||||
|
>
|
||||||
|
View Offer
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listingStatus === 'active') {
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const amount = parseFloat(offerAmount);
|
||||||
|
const closing_days = parseFloat(closingDuration);
|
||||||
|
if (amount > 0 && closing_days) {
|
||||||
|
try {
|
||||||
|
const response = await onOfferSubmit(amount, closing_days, contingencies);
|
||||||
|
if (response.status === 200 || response.status === 201) {
|
||||||
|
setSubmitted(true);
|
||||||
|
setError(null);
|
||||||
|
setTimeout(() => setSubmitted(false), 5000);
|
||||||
|
} else {
|
||||||
|
setError(response.message || 'An unknown error occurred.');
|
||||||
|
setSubmitted(false);
|
||||||
|
setTimeout(() => setError(null), 5000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to submit offer.');
|
||||||
|
setSubmitted(false);
|
||||||
|
setTimeout(() => setError(null), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !offerAmount || !closingDuration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Submit an Offer
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Your Offer Amount ($)"
|
||||||
|
type="number"
|
||||||
|
value={offerAmount}
|
||||||
|
onChange={(e) => setOfferAmount(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
helperText={
|
||||||
|
offerPercentage > 0
|
||||||
|
? `This offer is ${offerPercentage.toFixed(2)}% of the listing price.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Closing Duration (days)"
|
||||||
|
type="number"
|
||||||
|
value={closingDuration}
|
||||||
|
onChange={(e) => setClosingDuration(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
helperText={closingDuration ? `Estimated closing date: ${getClosingDate()}` : ''}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Typical contingencies include financing, inspection, and appraisal.">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Contingencies"
|
||||||
|
type="text"
|
||||||
|
value={contingencies}
|
||||||
|
onChange={(e) => setContingencies(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
>
|
||||||
|
Submit Offer
|
||||||
|
</Button>
|
||||||
|
{submitted && (
|
||||||
|
<Alert severity="success" sx={{ mt: 2 }}>
|
||||||
|
Your offer of ${offerAmount} has been submitted!
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Submit an Offer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" gutterBottom>
|
||||||
|
Offer is not available at the moment because the list is not active
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OfferSubmissionCard;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { OpenHouseAPI } from 'types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface OpenHouseCardProps {
|
||||||
|
openHouses: OpenHouseAPI[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
|
||||||
|
if (openHouses) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Open House Information
|
||||||
|
</Typography>
|
||||||
|
{openHouses.length > 0 ? (
|
||||||
|
<List dense>
|
||||||
|
{openHouses.map((openHouse, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={`${format(new Date(openHouse.listed_date), 'MMM d, yyyy')} at ${format(
|
||||||
|
new Date(`1970-01-01T${openHouse.start_time}`),
|
||||||
|
'h a',
|
||||||
|
)} - ${format(new Date(`1970-01-01T${openHouse.end_time}`), 'h a')}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
{index < openHouses.length - 1 && <Divider component="li" />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No upcoming open houses scheduled.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Open House Information
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No upcoming open houses scheduled.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenHouseCard;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// src/components/PropertyOwnerProfile/OpenHouseCard.tsx
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||||
|
import { OpenHouseAPI } from 'types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface OpenHouseCardProps {
|
||||||
|
openHouse: OpenHouseAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OpenHouseDialogContent: React.FC<OpenHouseCardProps> = ({ openHouse }) => {
|
||||||
|
const startTime = new Date(`${openHouse.start_time}`);
|
||||||
|
const endTime = new Date(`${openHouse.end_time}`);
|
||||||
|
|
||||||
|
console.log(endTime);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" component="div">
|
||||||
|
Open House at Property: {openHouse.property.address_line_1}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{openHouse.start_time}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{openHouse.end_time}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenHouseDialogContent;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import { UserAPI } from 'types';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: UserAPI;
|
||||||
|
onUpgrade: () => void;
|
||||||
|
onSave: (updatedUser: UserAPI) => void;
|
||||||
|
setMessage: (
|
||||||
|
value: React.SetStateAction<{
|
||||||
|
type: 'success' | 'error';
|
||||||
|
text: string;
|
||||||
|
} | null>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave, setMessage }) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedUser, setEditedUser] = useState<UserAPI>(user);
|
||||||
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
const handleEditToggle = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
// Cancel editing, revert to original user data
|
||||||
|
setEditedUser(user);
|
||||||
|
setFormErrors({});
|
||||||
|
}
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(editedUser);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (isEditing) {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setEditedUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
|
}));
|
||||||
|
// Clear error for the field being edited
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Enable editing in the top right' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
if (!editedUser.first_name.trim()) {
|
||||||
|
errors.first_name = 'First name is required.';
|
||||||
|
}
|
||||||
|
if (!editedUser.last_name.trim()) {
|
||||||
|
errors.last_name = 'Last name is required.';
|
||||||
|
}
|
||||||
|
if (!editedUser.email.trim()) {
|
||||||
|
errors.email = 'Email is required.';
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(editedUser.email)) {
|
||||||
|
errors.email = 'Invalid email address.';
|
||||||
|
}
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
onSave(editedUser);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Profile Information
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<Box>
|
||||||
|
<IconButton color="primary" onClick={handleSave}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IconButton color="primary" onClick={handleEditToggle}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="First Name"
|
||||||
|
name="first_name"
|
||||||
|
value={editedUser.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!formErrors.first_name}
|
||||||
|
helperText={formErrors.first_name}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Last Name"
|
||||||
|
name="last_name"
|
||||||
|
value={editedUser.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!formErrors.last_name}
|
||||||
|
helperText={formErrors.last_name}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email Address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={editedUser.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!formErrors.email}
|
||||||
|
helperText={formErrors.email}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
|
</Typography>
|
||||||
|
{user.tier === 'basic' && (
|
||||||
|
<Button variant="contained" color="primary" onClick={onUpgrade} sx={{ mt: 1 }}>
|
||||||
|
Upgrade to Premium
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle1">Notification Settings:</Typography>
|
||||||
|
{/* Example Checkboxes - You'd manage these with state too */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="productUpdateEmails"
|
||||||
|
checked={true} // Placeholder
|
||||||
|
onChange={() => {}} // Placeholder
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>{' '}
|
||||||
|
Product Update Emails
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="communicationEmails"
|
||||||
|
checked={true} // Placeholder
|
||||||
|
onChange={() => {}} // Placeholder
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>{' '}
|
||||||
|
Communication Emails
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{Object.keys(formErrors).length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Please correct the errors in the form.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileCard;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Box,
|
||||||
|
ImageList,
|
||||||
|
ImageListItem,
|
||||||
|
ImageListItemBar,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import MapComponent from '../../../../base/MapComponent';
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
|
||||||
|
interface PropertyCardProps {
|
||||||
|
property: PropertiesAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
|
||||||
|
// Dummy latitude and longitude for demonstration
|
||||||
|
// In a real app, you'd geocode the address to get these.
|
||||||
|
const demoLat = 34.0522;
|
||||||
|
const demoLng = -118.2437; // Example: Los Angeles coordinates
|
||||||
|
console.log(property);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
{property.pictures && property.pictures.length > 0 && (
|
||||||
|
<ImageList
|
||||||
|
cols={property.pictures.length > 1 ? 2 : 1}
|
||||||
|
rowHeight={164}
|
||||||
|
sx={{ maxWidth: 500 }}
|
||||||
|
>
|
||||||
|
{property.pictures.map((item, index) => (
|
||||||
|
<ImageListItem key={index}>
|
||||||
|
<img
|
||||||
|
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
|
||||||
|
${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
||||||
|
src={`${item}?w=164&h=164&fit=crop&auto=format`}
|
||||||
|
alt={`Property image ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<ImageListItemBar title={`Image ${index + 1}`} />
|
||||||
|
</ImageListItem>
|
||||||
|
))}
|
||||||
|
</ImageList>
|
||||||
|
)}
|
||||||
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||||
|
<strong>Description:</strong>
|
||||||
|
</Typography>
|
||||||
|
{property.description ? (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{property.description}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Button variant="contained">Generate Description</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="body1">
|
||||||
|
<strong>Stats:</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">Bathrooms: {property.num_bathrooms || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Features:{' '}
|
||||||
|
{property.features && property.features.length > 0
|
||||||
|
? property.features.join(', ')
|
||||||
|
: 'None'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">Market Value: ${property.market_value || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">Loan Amount: ${property.loan_amount || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
{property.latitude && property.longitude ? (
|
||||||
|
<MapComponent
|
||||||
|
lat={property.latitude}
|
||||||
|
lng={property.longitude}
|
||||||
|
address={property.address}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p>Error loading the map</p>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyCard;
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
|
|
||||||
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
|
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI, OpenHouseAPI } from 'types';
|
||||||
|
import ProfileCard from './ProfileCard';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import PropertyCard from './PropertyCard.';
|
||||||
|
import AddPropertyDialog from './AddPropertyDialog';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
|
import { ProfileProps } from 'pages/Profile/Profile';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import AddOpenHouseDialog from './AddOpenHouseDialog';
|
||||||
|
import OpenHouseDialogContent from './OpenHouseDialogContext';
|
||||||
|
|
||||||
|
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||||
|
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
|
||||||
|
|
||||||
|
const [openHouses, setOpenHouses] = useState<OpenHouseAPI[]>([]);
|
||||||
|
const [openAddOpenHouseDialog, setOpenAddOpenHouseDialog] = useState(false);
|
||||||
|
const [openHouseErrors, setOpenHouseErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPropertyOwner = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingData(true);
|
||||||
|
const { data }: AxiosResponse<PropertyOwnerAPI[]> =
|
||||||
|
await axiosInstance.get(`/property-owners/`);
|
||||||
|
if (data.length > 0) {
|
||||||
|
setUser(data[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPropertyOwner();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [properties, setProperties] = useState<PropertiesAPI[]>([]); //initialPropertiesData);
|
||||||
|
|
||||||
|
const [openAddPropertyDialog, setOpenAddPropertyDialog] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProperties = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingData(true);
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setProperties(data);
|
||||||
|
console.log('setting the user to: ', data[0].owner);
|
||||||
|
setUser(data[0].owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: openHousesData }: AxiosResponse<OpenHouseAPI[]> = await axiosInstance.get(
|
||||||
|
'/properties/open-houses/',
|
||||||
|
);
|
||||||
|
setOpenHouses(openHousesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProperties();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpgradeSubscription = async () => {
|
||||||
|
navigate('/upgrade/');
|
||||||
|
// const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
|
||||||
|
// ...user.user,
|
||||||
|
// tier: 'premium',
|
||||||
|
// });
|
||||||
|
// if (data !== null && user) {
|
||||||
|
// const updateUser: PropertyOwnerAPI = {
|
||||||
|
// ...user,
|
||||||
|
// user: data,
|
||||||
|
// };
|
||||||
|
// setUser(updateUser);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||||
|
// setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async (editedUser: UserAPI) => {
|
||||||
|
editedUser.profile_created = true;
|
||||||
|
const { data }: AxiosResponse<UserAPI> = await axiosInstance.post(`/user/`, {
|
||||||
|
...editedUser,
|
||||||
|
});
|
||||||
|
if (data !== null && user) {
|
||||||
|
const updateUser: PropertyOwnerAPI = {
|
||||||
|
...user,
|
||||||
|
user: data,
|
||||||
|
};
|
||||||
|
setUser(updateUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddPropertyDialog = () => {
|
||||||
|
setOpenAddPropertyDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddPropertyDialog = () => {
|
||||||
|
setOpenAddPropertyDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddOpenHouseDialog = () => {
|
||||||
|
setOpenAddOpenHouseDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddOpenHouseDialog = () => {
|
||||||
|
setOpenAddOpenHouseDialog(false);
|
||||||
|
setOpenHouseErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOpenHouse = async (
|
||||||
|
newOpenHouseData: Omit<OpenHouseAPI, 'id'> & { listed_date: string },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<OpenHouseAPI> = await axiosInstance.post(
|
||||||
|
'/properties/open-houses/',
|
||||||
|
newOpenHouseData,
|
||||||
|
);
|
||||||
|
setOpenHouses((prev) => [...prev, data]);
|
||||||
|
setMessage({ type: 'success', text: 'Open house added successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
setOpenAddOpenHouseDialog(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setOpenHouseErrors(error.response.data);
|
||||||
|
setMessage({ type: 'error', text: 'Error adding open house.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProperty = (
|
||||||
|
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||||
|
) => {
|
||||||
|
if (user) {
|
||||||
|
const newProperty: PropertyRequestAPI = {
|
||||||
|
...newPropertyData,
|
||||||
|
owner: user.user.id,
|
||||||
|
// created_at: new Date().toISOString().split('T')[0],
|
||||||
|
// last_updated: new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
newProperty.created_at = new Date().toISOString().split('T')[0];
|
||||||
|
newProperty.last_updated = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
newProperty.open_houses = [];
|
||||||
|
|
||||||
|
console.log(newProperty);
|
||||||
|
const { data, error } = axiosInstance.post('/properties/', {
|
||||||
|
...newProperty,
|
||||||
|
});
|
||||||
|
const updateNewProperty: PropertiesAPI = {
|
||||||
|
...newProperty,
|
||||||
|
owner: user,
|
||||||
|
};
|
||||||
|
setProperties((prev) => [...prev, updateNewProperty]);
|
||||||
|
setMessage({ type: 'success', text: 'Property added successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||||
|
`/properties/${updatedProperty.id}/`,
|
||||||
|
{
|
||||||
|
...updatedProperty,
|
||||||
|
owner: account.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updatedProperties = properties.map((item) => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, ...data };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
setProperties(updatedProperties);
|
||||||
|
setMessage({ type: 'success', text: 'Property has been updated' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProperty = async (propertyId: number) => {
|
||||||
|
try {
|
||||||
|
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||||
|
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||||
|
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(user);
|
||||||
|
if (loadingData) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||||
|
User Profile Dashboard
|
||||||
|
</Typography>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<ProfileCard
|
||||||
|
user={user.user}
|
||||||
|
onUpgrade={handleUpgradeSubscription}
|
||||||
|
onSave={handleSaveProfile}
|
||||||
|
setMessage={setMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
|
||||||
|
My Properties
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={handleOpenAddPropertyDialog}>
|
||||||
|
Add Property
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{properties.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="textSecondary">
|
||||||
|
You currently have no properties listed.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<Grid size={{ xs: 12 }} key={property.id}>
|
||||||
|
{/* <PropertyCard property={property} /> */}
|
||||||
|
<PropertyDetailCard
|
||||||
|
property={property}
|
||||||
|
isPublicPage={false}
|
||||||
|
onSave={handleSaveProperty}
|
||||||
|
isOwnerView={true}
|
||||||
|
onDelete={handleDeleteProperty}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddPropertyDialog
|
||||||
|
open={openAddPropertyDialog}
|
||||||
|
onClose={handleCloseAddPropertyDialog}
|
||||||
|
onAddProperty={handleAddProperty}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{properties.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
|
||||||
|
My Open Houses
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={handleOpenAddOpenHouseDialog}>
|
||||||
|
Add Open House
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{openHouses.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="textSecondary">
|
||||||
|
You have no open houses scheduled.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{openHouses.map((openHouse) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }} key={openHouse.id}>
|
||||||
|
{/* You will create a component to display the open house details */}
|
||||||
|
<OpenHouseDialogContent openHouse={openHouse} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Please add a property before you can schedule an open house.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddOpenHouseDialog
|
||||||
|
open={openAddOpenHouseDialog}
|
||||||
|
onClose={handleCloseAddOpenHouseDialog}
|
||||||
|
onAddOpenHouse={handleAddOpenHouse}
|
||||||
|
properties={properties} // Pass the properties to the dialog
|
||||||
|
errors={openHouseErrors}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyOwnerProfile;
|
||||||
@@ -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,188 @@
|
|||||||
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
|
import { UserAPI, VendorAPI } from 'types';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Rating,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import VendorProfileCard from './VendorProfileCard';
|
||||||
|
import ServicesCard from './ServiceCard';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
|
import { ProfileProps } from 'pages/Profile/Profile';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const VendorProfile = ({ account }: ProfileProps): ReactElement => {
|
||||||
|
const [vendor, setVendor] = useState<VendorAPI | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVendor = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<VendorAPI[]> = await axiosInstance.get('/vendors/');
|
||||||
|
if (data.length > 0) {
|
||||||
|
setVendor(data[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchVendor();
|
||||||
|
}, []);
|
||||||
|
if (loadingData) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendor === null) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
} else {
|
||||||
|
const handleUpgradeSubscription = () => {
|
||||||
|
navigate('/upgrade/');
|
||||||
|
// setVendor((prev) => ({
|
||||||
|
// ...prev,
|
||||||
|
// user: { ...prev.user, tier: 'premium' },
|
||||||
|
// }));
|
||||||
|
// setMessage({ type: 'success', text: 'Subscription upgraded to Premium!' });
|
||||||
|
// setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveVendorProfile = (updatedVendor: VendorAPI) => {
|
||||||
|
try {
|
||||||
|
let newUpdatedVendor;
|
||||||
|
// if the email is the same, remove it
|
||||||
|
if (updatedVendor.user.email === account.email) {
|
||||||
|
const { user, ...reducedVendor } = updatedVendor;
|
||||||
|
const { email, ...reducedUser } = user;
|
||||||
|
newUpdatedVendor = {
|
||||||
|
user: {
|
||||||
|
profile_created: true,
|
||||||
|
...reducedUser,
|
||||||
|
},
|
||||||
|
...reducedVendor,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newUpdatedVendor = updatedVendor;
|
||||||
|
}
|
||||||
|
newUpdatedVendor.user.profile_created = true;
|
||||||
|
|
||||||
|
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||||
|
...newUpdatedVendor,
|
||||||
|
});
|
||||||
|
setVendor(updatedVendor);
|
||||||
|
setMessage({ type: 'success', text: 'Vendor profile updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setMessage({ type: 'error', text: 'Error saving the profile. Try again.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveServices = (updatedServices: string[]) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||||
|
services: updatedServices,
|
||||||
|
});
|
||||||
|
setVendor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
services: updatedServices,
|
||||||
|
}));
|
||||||
|
setMessage({ type: 'success', text: 'Services updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error saving services. Try again.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveServiceAreas = (updatedServiceAreas: string[]) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = axiosInstance.patch(`/vendors/${account.id}/`, {
|
||||||
|
service_areas: updatedServiceAreas,
|
||||||
|
});
|
||||||
|
|
||||||
|
setVendor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
service_areas: updatedServiceAreas,
|
||||||
|
}));
|
||||||
|
setMessage({ type: 'success', text: 'Service areas updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error saving service area. Try again.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom color="background.paper">
|
||||||
|
Vendor Profile Dashboard
|
||||||
|
</Typography>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mb: 3, display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
{vendor.profile_picture && (
|
||||||
|
<Avatar
|
||||||
|
src={vendor.profile_picture}
|
||||||
|
alt={vendor.business_name}
|
||||||
|
sx={{ width: 80, height: 80 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5">{vendor.business_name}</Typography>
|
||||||
|
<Typography variant="body1" color="textSecondary">
|
||||||
|
{vendor.business_type}
|
||||||
|
</Typography>
|
||||||
|
{vendor.average_rating !== undefined && vendor.num_reviews !== undefined && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Rating value={vendor.average_rating} precision={0.1} readOnly />
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ ml: 1 }}>
|
||||||
|
({vendor.num_reviews} reviews)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<VendorProfileCard
|
||||||
|
vendor={vendor}
|
||||||
|
onUpgrade={handleUpgradeSubscription}
|
||||||
|
onSave={handleSaveVendorProfile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
<ServicesCard
|
||||||
|
services={vendor.services}
|
||||||
|
onSave={handleSaveServices}
|
||||||
|
serviceAreas={vendor.service_areas}
|
||||||
|
onSaveServiceAreas={handleSaveServiceAreas}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* You can add more sections here, e.g., for portfolio, reviews, etc. */}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorProfile;
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import { VendorAPI, UserAPI } from '../types/api';
|
||||||
|
import { test_autocomplete } from 'data/mock_autocomplete_results';
|
||||||
|
import { AutocompleteDataResponseAPI } from 'types';
|
||||||
|
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
|
||||||
|
import { extractLatLon } from 'utils';
|
||||||
|
import { PlacePrediction } from './AddPropertyDialog';
|
||||||
|
|
||||||
|
interface VendorProfileCardProps {
|
||||||
|
vendor: VendorAPI;
|
||||||
|
onUpgrade: () => void;
|
||||||
|
onSave: (updatedVendor: VendorAPI) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade, onSave }) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedVendor, setEditedVendor] = useState<VendorAPI>(vendor);
|
||||||
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
const [autocompleteOptions, setAutocompleteOptions] = useState<PlacePrediction[]>([]);
|
||||||
|
const [autocompleteLoading, setAutocompleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleEditToggle = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditedVendor(vendor); // Revert on cancel
|
||||||
|
setFormErrors({});
|
||||||
|
}
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e:
|
||||||
|
| React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
| React.ChangeEvent<{ name?: string; value: unknown }>,
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditedVendor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name as string]: value,
|
||||||
|
// Handle nested user properties if you allow editing them here
|
||||||
|
user:
|
||||||
|
name === 'email' || name === 'first_name' || name === 'last_name'
|
||||||
|
? { ...prev.user, [name]: value }
|
||||||
|
: prev.user,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (formErrors[name as string]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name as string];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
if (!editedVendor.business_name.trim()) {
|
||||||
|
errors.business_name = 'Business name is required.';
|
||||||
|
}
|
||||||
|
if (!editedVendor.phone_number.trim()) {
|
||||||
|
errors.phone_number = 'Phone number is required.';
|
||||||
|
} else if (!/^\d{10}$/.test(editedVendor.phone_number.replace(/\D/g, ''))) {
|
||||||
|
errors.phone_number = 'Invalid phone number format (10 digits).';
|
||||||
|
}
|
||||||
|
if (!editedVendor.address.trim()) {
|
||||||
|
errors.address = 'Address is required.';
|
||||||
|
}
|
||||||
|
if (!editedVendor.city.trim()) {
|
||||||
|
errors.city = 'City is required.';
|
||||||
|
}
|
||||||
|
if (!editedVendor.state.trim()) {
|
||||||
|
errors.state = 'State is required.';
|
||||||
|
}
|
||||||
|
if (!editedVendor.zip_code.trim()) {
|
||||||
|
errors.zip_code = 'Zip code is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
onSave(editedVendor);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
//setNewProperty((prev) => ({ ...prev, [name]: value }));
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteInputChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
|
if (value.length > 2) {
|
||||||
|
if (test) {
|
||||||
|
data = test_autocomplete.data.filter((item) => item.address.includes(value));
|
||||||
|
// filter the data here
|
||||||
|
} else {
|
||||||
|
const { data } = await axiosRealEstateApi.post<AutocompleteDataResponseAPI[]>(
|
||||||
|
'AutoComplete',
|
||||||
|
{
|
||||||
|
search: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setAutocompleteOptions(
|
||||||
|
data.map((item) => ({
|
||||||
|
description: item.address,
|
||||||
|
place_id: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('we need more characters');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressAutocompleteChange = async (
|
||||||
|
event: React.SyntheticEvent,
|
||||||
|
value: PlacePrediction | null,
|
||||||
|
) => {
|
||||||
|
console.log('here we go', value);
|
||||||
|
if (1) {
|
||||||
|
const data = test_autocomplete.data.filter((item) => item.id === value.place_id);
|
||||||
|
if (data.length > 0) {
|
||||||
|
const item = data[0];
|
||||||
|
const coordinates = extractLatLon(item.location);
|
||||||
|
setEditedVendor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
address: item.address,
|
||||||
|
city: item.city,
|
||||||
|
state: item.state,
|
||||||
|
zip_code: item.zip,
|
||||||
|
latitude: Number(coordinates.latitude),
|
||||||
|
longitude: Number(coordinates.longitude),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// use the api here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Vendor Profile Information
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<Box>
|
||||||
|
<IconButton color="primary" onClick={handleSave}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IconButton color="primary" onClick={handleEditToggle}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="First Name"
|
||||||
|
name="first_name"
|
||||||
|
value={editedVendor.user.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Last Name"
|
||||||
|
name="last_name"
|
||||||
|
value={editedVendor.user.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email Address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={editedVendor.user.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Business Name"
|
||||||
|
name="business_name"
|
||||||
|
value={editedVendor.business_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.business_name}
|
||||||
|
helperText={formErrors.business_name}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<FormControl fullWidth disabled={!isEditing}>
|
||||||
|
<InputLabel id="business-type-label">Business Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="business-type-label"
|
||||||
|
id="business_type"
|
||||||
|
name="business_type"
|
||||||
|
value={editedVendor.business_type}
|
||||||
|
label="Business Type"
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<MenuItem value="electrician">Electrician</MenuItem>
|
||||||
|
<MenuItem value="carpenter">Carpenter</MenuItem>
|
||||||
|
<MenuItem value="plumber">Plumber</MenuItem>
|
||||||
|
<MenuItem value="inspector">Inspector</MenuItem>
|
||||||
|
<MenuItem value="lendor">Lendor</MenuItem>
|
||||||
|
<MenuItem value="other">Other</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Phone Number"
|
||||||
|
name="phone_number"
|
||||||
|
value={editedVendor.phone_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.phone_number}
|
||||||
|
helperText={formErrors.phone_number}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={autocompleteOptions}
|
||||||
|
getOptionLabel={(option) => option.description}
|
||||||
|
inputValue={editedVendor.adress}
|
||||||
|
noOptionsText={'Type at least 3 characters'}
|
||||||
|
onInputChange={handleAddressAutocompleteInputChange}
|
||||||
|
onChange={handleAddressAutocompleteChange}
|
||||||
|
isOptionEqualToValue={(option, value) => option.place_id === value.place_id}
|
||||||
|
loading={autocompleteLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
fullWidth
|
||||||
|
label="Address"
|
||||||
|
name="address"
|
||||||
|
required
|
||||||
|
error={!!formErrors.address}
|
||||||
|
helperText={formErrors.address}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{autocompleteLoading ? (
|
||||||
|
<CircularProgress color="inherit" size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
value={editedVendor.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.city}
|
||||||
|
helperText={formErrors.city}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={editedVendor.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.state}
|
||||||
|
helperText={formErrors.state}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zip_code"
|
||||||
|
value={editedVendor.zip_code}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
required
|
||||||
|
error={!!formErrors.zip_code}
|
||||||
|
helperText={formErrors.zip_code}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Business Description"
|
||||||
|
name="description"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={editedVendor.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Website URL"
|
||||||
|
name="website"
|
||||||
|
value={editedVendor.website || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Certifications (comma-separated)"
|
||||||
|
name="certifications"
|
||||||
|
value={editedVendor.certifications?.join(', ') || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditedVendor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
certifications: e.target.value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
|
</Typography>
|
||||||
|
{vendor.user.tier === 'basic' && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={onUpgrade}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
disabled={isEditing}
|
||||||
|
>
|
||||||
|
Upgrade to Premium
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{Object.keys(formErrors).length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Please correct the errors in the form.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorProfileCard;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
|
||||||
|
|
||||||
|
interface LogInNotificationCardProps {}
|
||||||
|
|
||||||
|
const LogInNotificationCard: React.FC<LogInNotificationCardProps> = ({}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goToLogin = async () => {
|
||||||
|
navigate('/authentication/login');
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Want to know more?
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Sign in or create an account to view the seller disclosure document and message the owner
|
||||||
|
with any questions.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" fullWidth onClick={goToLogin}>
|
||||||
|
Log In
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogInNotificationCard;
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Box,
|
||||||
|
ImageList,
|
||||||
|
ImageListItem,
|
||||||
|
ImageListItemBar,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
import MapComponent from '../../../../base/MapComponent';
|
||||||
|
import FormattedListingText from 'components/base/FormattedListingText';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface PropertyDetailCardProps {
|
||||||
|
property: PropertiesAPI;
|
||||||
|
onSave: (updatedProperty: PropertiesAPI) => void;
|
||||||
|
isPublicPage?: boolean;
|
||||||
|
isOwnerView?: boolean; // True if the current user is the owner, allows editing
|
||||||
|
onDelete?: (propertyId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||||
|
property,
|
||||||
|
onSave,
|
||||||
|
isPublicPage,
|
||||||
|
isOwnerView = false,
|
||||||
|
onDelete = null,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isGenerating, setIsGernerating] = useState<boolean>(false);
|
||||||
|
const [editedProperty, setEditedProperty] = useState<PropertiesAPI>(property);
|
||||||
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// Ensure latitude and longitude are defined, use defaults if not available
|
||||||
|
const displayLat = Number(editedProperty.latitude) ?? 34.0522; // Default to LA for demo
|
||||||
|
const displayLng = Number(editedProperty.longitude) ?? -118.2437; // Default to LA for demo
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleEditToggle = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditedProperty(property); // Revert changes on cancel
|
||||||
|
setFormErrors({});
|
||||||
|
}
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditedProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
setEditedProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: isNaN(numValue) ? '' : numValue,
|
||||||
|
}));
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeaturesChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEditedProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
features: value
|
||||||
|
.split(',')
|
||||||
|
.map((f) => f.trim())
|
||||||
|
.filter((f) => f),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewPublicListing = () => {
|
||||||
|
navigate(`/property/${property.id}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const filesArray = Array.from(e.target.files);
|
||||||
|
// In a real app, you'd upload these files to a server and get URLs back
|
||||||
|
console.log(filesArray);
|
||||||
|
filesArray.map((file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('Property', property.id.toString());
|
||||||
|
|
||||||
|
const response = axiosInstance.post('/properties/pictures/', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: { [key: string]: string } = {};
|
||||||
|
if (!editedProperty.address.trim()) errors.address = 'Address is required.';
|
||||||
|
if (!editedProperty.city.trim()) errors.city = 'City is required.';
|
||||||
|
if (!editedProperty.state.trim()) errors.state = 'State is required.';
|
||||||
|
if (!editedProperty.zip_code.trim()) errors.zip_code = 'Zip code is required.';
|
||||||
|
if (editedProperty.sq_ft !== undefined && editedProperty.sq_ft <= 0)
|
||||||
|
errors.sq_ft = 'Square footage must be positive.';
|
||||||
|
if (editedProperty.num_bedrooms !== undefined && editedProperty.num_bedrooms < 0)
|
||||||
|
errors.num_bedrooms = 'Bedrooms cannot be negative.';
|
||||||
|
if (editedProperty.num_bathrooms !== undefined && editedProperty.num_bathrooms < 0)
|
||||||
|
errors.num_bathrooms = 'Bathrooms cannot be negative.';
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
onSave(editedProperty);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (isOwnerView && !isPublicPage && onDelete) {
|
||||||
|
onDelete(property.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateDescription = async () => {
|
||||||
|
setIsGernerating(true);
|
||||||
|
|
||||||
|
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
||||||
|
setEditedProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: response.data.description,
|
||||||
|
}));
|
||||||
|
setIsGernerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h5" component="h2">
|
||||||
|
{isEditing ? 'Edit Property Details' : property.address}
|
||||||
|
</Typography>
|
||||||
|
{isOwnerView &&
|
||||||
|
!isPublicPage &&
|
||||||
|
(isEditing ? (
|
||||||
|
<Box>
|
||||||
|
<IconButton color="primary" onClick={handleDelete}>
|
||||||
|
<DeleteForeverIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="primary" onClick={handleSave}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="secondary" onClick={handleEditToggle}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<IconButton color="primary" onClick={handleEditToggle}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Property Address & Basic Info */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 8 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Address"
|
||||||
|
name="address"
|
||||||
|
value={editedProperty.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.address}
|
||||||
|
helperText={formErrors.address}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
value={editedProperty.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.city}
|
||||||
|
helperText={formErrors.city}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={editedProperty.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.state}
|
||||||
|
helperText={formErrors.state}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zip_code"
|
||||||
|
value={editedProperty.zip_code}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
error={!!formErrors.zip_code}
|
||||||
|
helperText={formErrors.zip_code}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Stack direction="row">
|
||||||
|
<Typography variant="h6">
|
||||||
|
{property.street}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
{isOwnerView && !isPublicPage && (
|
||||||
|
<Button onClick={handleViewPublicListing}>View Public Listing</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Pictures */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Pictures:
|
||||||
|
</Typography>
|
||||||
|
{editedProperty.pictures && editedProperty.pictures.length > 0 ? (
|
||||||
|
<ImageList
|
||||||
|
cols={editedProperty.pictures.length > 1 ? 2 : 1}
|
||||||
|
rowHeight={164}
|
||||||
|
sx={{ maxWidth: 500 }}
|
||||||
|
>
|
||||||
|
{editedProperty.pictures.map((item, index) => (
|
||||||
|
<ImageListItem key={item.id}>
|
||||||
|
<img
|
||||||
|
srcSet={`${item.image}?w=164&h=164&fit=crop&auto=format 1x, ${item.image}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
||||||
|
src={`${item.image}?w=164&h=164&fit=crop&auto=format`}
|
||||||
|
alt={`Property image ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<ImageListItemBar title={`Image ${index + 1}`} />
|
||||||
|
</ImageListItem>
|
||||||
|
))}
|
||||||
|
</ImageList>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
No pictures available.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
startIcon={<AddPhotoAlternateIcon />}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Add More Pictures
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePictureUpload}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Description:
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<Stack direction="column">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
value={editedProperty.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="contained" onClick={generateDescription} disabled={isGenerating}>
|
||||||
|
Generate a description
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<FormattedListingText text={property.description} />
|
||||||
|
// <Typography variant="body2" color="textSecondary">
|
||||||
|
// {property.description || 'No description provided.'}
|
||||||
|
// </Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Stats:
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Sq Ft"
|
||||||
|
name="sq_ft"
|
||||||
|
type="number"
|
||||||
|
value={editedProperty.sq_ft || ''}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
error={!!formErrors.sq_ft}
|
||||||
|
helperText={formErrors.sq_ft}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Bedrooms"
|
||||||
|
name="num_bedrooms"
|
||||||
|
type="number"
|
||||||
|
value={editedProperty.num_bedrooms || ''}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
error={!!formErrors.num_bedrooms}
|
||||||
|
helperText={formErrors.num_bedrooms}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Bathrooms"
|
||||||
|
name="num_bathrooms"
|
||||||
|
type="number"
|
||||||
|
value={editedProperty.num_bathrooms || ''}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
error={!!formErrors.num_bathrooms}
|
||||||
|
helperText={formErrors.num_bathrooms}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Features (comma-separated)"
|
||||||
|
name="features"
|
||||||
|
value={editedProperty.features.join(', ') || ''}
|
||||||
|
onChange={handleFeaturesChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Market Value"
|
||||||
|
name="market_value"
|
||||||
|
value={editedProperty.market_value}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Amount"
|
||||||
|
name="loan_amount"
|
||||||
|
value={editedProperty.loan_amount}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Term (years)"
|
||||||
|
name="loan_term"
|
||||||
|
type="number"
|
||||||
|
value={editedProperty.loan_term || ''}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Loan Start Date"
|
||||||
|
name="loan_start_date"
|
||||||
|
type="date"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={editedProperty.loan_start_date}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Bathrooms: {property.num_bathrooms || 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Features:{' '}
|
||||||
|
{property.features && property.features.length > 0
|
||||||
|
? property.features.join(', ')
|
||||||
|
: 'None'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Market Value: ${property.market_value || 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Loan Amount: ${property.loan_amount || 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Location on Map:
|
||||||
|
</Typography>
|
||||||
|
<MapComponent lat={displayLat} lng={displayLng} address={property.address} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{Object.keys(formErrors).length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Please correct the errors in the form.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyDetailCard;
|
||||||
@@ -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,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, Typography, Button, Box, CardMedia } from '@mui/material';
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
|
||||||
|
interface PropertyListItemProps {
|
||||||
|
property: PropertiesAPI;
|
||||||
|
onViewDetails: (propertyId: number) => void; // For navigation in search page
|
||||||
|
isPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyListItem: React.FC<PropertyListItemProps> = ({
|
||||||
|
property,
|
||||||
|
onViewDetails,
|
||||||
|
isPublic = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleViewDetailsClick = () => {
|
||||||
|
// Navigate to the full detail page for this property
|
||||||
|
if (!isPublic) {
|
||||||
|
navigate(`/property/${property.id}/?search=1`);
|
||||||
|
} else {
|
||||||
|
navigate(`/public/${property.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const value_price = property.listed_price ? property.listed_price : property.market_value;
|
||||||
|
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{ display: 'flex', mb: 2, '&:hover': { boxShadow: 6 }, cursor: 'pointer' }}
|
||||||
|
onClick={handleViewDetailsClick}
|
||||||
|
>
|
||||||
|
{property.pictures && property.pictures.length > 0 && (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
sx={{ width: 150, height: 150, flexShrink: 0, objectFit: 'cover' }}
|
||||||
|
image={property.pictures[0]}
|
||||||
|
alt={property.address}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
|
||||||
|
<CardContent sx={{ flex: '1 0 auto' }}>
|
||||||
|
<Typography component="div" variant="h6">
|
||||||
|
{property.address}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary">
|
||||||
|
{property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{property.num_bedrooms} Beds | {property.num_bathrooms} Baths | {property.sq_ft} Sq Ft
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mt: 1 }}>
|
||||||
|
{value_text}: <strong>${value_price}</strong>
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
pl: 1,
|
||||||
|
pb: 1,
|
||||||
|
pr: 2,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="small" onClick={handleViewDetailsClick}>
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyListItem;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
|
||||||
|
interface SearchFilters {
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
minSqFt: number | '';
|
||||||
|
maxSqFt: number | '';
|
||||||
|
minBedrooms: number | '';
|
||||||
|
maxBedrooms: number | '';
|
||||||
|
minBathrooms: number | '';
|
||||||
|
maxBathrooms: number | '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertySearchFiltersProps {
|
||||||
|
onSearch: (filters: SearchFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFilters: SearchFilters = {
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zipCode: '',
|
||||||
|
minSqFt: '',
|
||||||
|
maxSqFt: '',
|
||||||
|
minBedrooms: '',
|
||||||
|
maxBedrooms: '',
|
||||||
|
minBathrooms: '',
|
||||||
|
maxBathrooms: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
|
||||||
|
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const numValue = value === '' ? '' : parseFloat(value);
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: numValue }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchClick = () => {
|
||||||
|
onSearch(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearClick = () => {
|
||||||
|
setFilters(initialFilters);
|
||||||
|
onClear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = () => {
|
||||||
|
setExpanded(!expanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Property Filters
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label="toggle filters"
|
||||||
|
>
|
||||||
|
<ExpandMoreIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Address Keyword"
|
||||||
|
name="address"
|
||||||
|
value={filters.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
value={filters.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={filters.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zipCode"
|
||||||
|
value={filters.zipCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Sq Ft"
|
||||||
|
name="minSqFt"
|
||||||
|
type="number"
|
||||||
|
value={filters.minSqFt}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Sq Ft"
|
||||||
|
name="maxSqFt"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxSqFt}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Bedrooms"
|
||||||
|
name="minBedrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.minBedrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Bedrooms"
|
||||||
|
name="maxBedrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxBedrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Bathrooms"
|
||||||
|
name="minBathrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.minBathrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Bathrooms"
|
||||||
|
name="maxBathrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxBathrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertySearchFilters;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
|
|
||||||
|
const getIcon = (
|
||||||
|
savedProperty: SavedPropertiesAPI | null,
|
||||||
|
): typeof FavoriteBorderIcon | typeof FavoriteIcon => {
|
||||||
|
return savedProperty ? FavoriteIcon : FavoriteBorderIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PropertyStatusCardProps {
|
||||||
|
property: PropertiesAPI;
|
||||||
|
isOwner: boolean;
|
||||||
|
onStatusChange?: (string) => void;
|
||||||
|
onSavedPropertySave?: () => void;
|
||||||
|
savedProperty: SavedPropertiesAPI | null;
|
||||||
|
sellerDisclosureExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||||
|
property,
|
||||||
|
isOwner,
|
||||||
|
onStatusChange,
|
||||||
|
onSavedPropertySave,
|
||||||
|
savedProperty,
|
||||||
|
sellerDisclosureExists,
|
||||||
|
}) => {
|
||||||
|
const handleStatusChange = (e) => {
|
||||||
|
const newStatus = e.target.value;
|
||||||
|
if (newStatus === 'active' && !sellerDisclosureExists) {
|
||||||
|
alert('A seller disclosure document is required before putting the property on the market.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStatusChange(newStatus);
|
||||||
|
};
|
||||||
|
const getStatusColor = (status: PropertiesAPI['property_status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'success';
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'contingent':
|
||||||
|
return 'primary';
|
||||||
|
case 'sold':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeSinceListed = (dateString: string) => {
|
||||||
|
const listedDate = new Date(dateString.split('T')[0]);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInMs = now.getTime() - listedDate.getTime();
|
||||||
|
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||||
|
return `${diffInDays} days`;
|
||||||
|
};
|
||||||
|
const getTimeOnMarketString = (status: PropertiesAPI['property_status'], listed_date: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return timeSinceListed(listed_date) + ' On Market';
|
||||||
|
case 'pending':
|
||||||
|
return timeSinceListed(listed_date) + ' On Market';
|
||||||
|
case 'contingent':
|
||||||
|
return timeSinceListed(listed_date) + ' On Market';
|
||||||
|
case 'sold':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timeOnMarketString: string = getTimeOnMarketString(
|
||||||
|
property.property_status,
|
||||||
|
property?.listed_date ? property?.listed_date : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Property Status
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
|
||||||
|
${property.listed_price ? property.listed_price : property.market_value}
|
||||||
|
</Typography>
|
||||||
|
{isOwner ? (
|
||||||
|
<Select
|
||||||
|
value={property.property_status}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
displayEmpty
|
||||||
|
variant="standard"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="off_market" disabled={property.property_status === 'off_market'}>
|
||||||
|
Off Market
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="active" disabled={property.property_status === 'active'}>
|
||||||
|
Active
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label={property.property_status.toUpperCase()}
|
||||||
|
color={getStatusColor(property.property_status)}
|
||||||
|
sx={{ fontSize: '1rem', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<VisibilityIcon color="primary" sx={{ mr: 1 }} />
|
||||||
|
<Typography variant="body1">{property.views} Views</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
{isOwner ? (
|
||||||
|
<FavoriteIcon color="primary" sx={{ mr: 1 }} />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
component={getIcon(savedProperty)}
|
||||||
|
color="primary"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
onClick={onSavedPropertySave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body1">{property.saves} Saves</Typography>
|
||||||
|
</Box>
|
||||||
|
{timeOnMarketString && (
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<AccessTimeIcon color="primary" sx={{ mr: 1 }} />
|
||||||
|
<Typography variant="body1">{timeOnMarketString}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyStatusCard;
|
||||||
@@ -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,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Box } from '@mui/material';
|
||||||
|
import SellerDisclosureDisplay from '../Documents/SellerDisclosureDisplay';
|
||||||
|
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
|
||||||
|
|
||||||
|
interface SellerDisclosureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
disclosureData: SellerDisclosureData;
|
||||||
|
property: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute' as 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: '80%', // Make it wider to accommodate the display
|
||||||
|
maxWidth: '900px',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '2px solid #000',
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SellerDisclosureDialog: React.FC<SellerDisclosureDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-labelledby="seller-disclosure-modal-title"
|
||||||
|
aria-describedby="seller-disclosure-modal-description"
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<SellerDisclosureDisplay disclosureData={disclosureData} property={property} />
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerDisclosureDialog;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import SellerDisclosureDialog from './SellerDisclosureDialog';
|
||||||
|
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
|
||||||
|
|
||||||
|
interface SellerInformationCardProps {
|
||||||
|
sellerDisclosureExists: boolean;
|
||||||
|
onSendMessage: () => void;
|
||||||
|
disclosureData?: SellerDisclosureData;
|
||||||
|
property?: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerInformationCard: React.FC<SellerInformationCardProps> = ({
|
||||||
|
sellerDisclosureExists,
|
||||||
|
onSendMessage,
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => setOpen(false);
|
||||||
|
|
||||||
|
if (accountLoading) {
|
||||||
|
return null; // Or a loading indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Seller Disclosure & Questions
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Create an account to view the seller disclosure document and message the owner with any
|
||||||
|
questions.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => navigate('/signup')}>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Seller Disclosure & Questions
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
disabled={!sellerDisclosureExists}
|
||||||
|
onClick={handleOpen}
|
||||||
|
>
|
||||||
|
{sellerDisclosureExists
|
||||||
|
? 'View Seller Disclosure'
|
||||||
|
: 'Seller Disclosure Not Available'}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button variant="contained" fullWidth onClick={onSendMessage}>
|
||||||
|
Ask a Question
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{disclosureData && property && (
|
||||||
|
<SellerDisclosureDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
disclosureData={disclosureData}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerInformationCard;
|
||||||
@@ -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,83 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { SupportCaseApi } from 'types';
|
||||||
|
|
||||||
|
type CreateSupportCaseDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
createSupportCase: (supportCase: Omit<SupportCaseApi, 'id' | 'status' | 'messages' | 'created_at' | 'updated_at'>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryOptions = ['question', 'bug', 'other'];
|
||||||
|
|
||||||
|
const CreateSupportCaseDialogContent = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
createSupportCase,
|
||||||
|
}: CreateSupportCaseDialogProps): ReactElement => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (title && description && category) {
|
||||||
|
createSupportCase({ title, description, category });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
|
||||||
|
<DialogTitle>Create a New Support Case</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="title"
|
||||||
|
label="Title"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="description"
|
||||||
|
label="Description"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
variant="outlined"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={categoryOptions}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
setCategory(newValue);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Category" variant="outlined" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreate} color="primary" disabled={!title || !description || !category}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSupportCaseDialogContent;
|
||||||
@@ -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)}
|
||||||
|
disabled={category.numVendors == 0}
|
||||||
|
>
|
||||||
|
View Vendors
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorCategoryCard;
|
||||||
166
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorDetail.tsx
vendored
Normal file
166
ditch-the-agent/src/components/sections/dashboard/Home/Vendor/VendorDetail.tsx
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// src/components/VendorApp/VendorDetail.tsx
|
||||||
|
import React, { useContext, useEffect } from 'react';
|
||||||
|
import { Box, Typography, Paper, Avatar, Rating, Grid, Button, Stack } from '@mui/material';
|
||||||
|
|
||||||
|
import PhoneIcon from '@mui/icons-material/Phone';
|
||||||
|
import EmailIcon from '@mui/icons-material/Email';
|
||||||
|
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||||
|
import { ConverationAPI, VendorItem } from 'types';
|
||||||
|
import VendorMap from './VendorMap';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import MapComponent from 'components/base/MapComponent';
|
||||||
|
|
||||||
|
interface VendorDetailProps {
|
||||||
|
vendor: VendorItem;
|
||||||
|
showMessageBtn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
const createMessage = async () => {
|
||||||
|
// First see if there is one already
|
||||||
|
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
|
||||||
|
`/conversations/?vendor=${vendor.id}`,
|
||||||
|
);
|
||||||
|
if (data.length === 0) {
|
||||||
|
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.post(
|
||||||
|
`/conversations/`,
|
||||||
|
{
|
||||||
|
property_owner: account?.id,
|
||||||
|
vendor: vendor.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
navigate(`/conversations/?selectedConversation=${data[0].id}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/conversations/?selectedConversation=${data[0].id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!vendor) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
No vendor selected
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// increment the vendor view count
|
||||||
|
const incrementVendorCount = async () => {
|
||||||
|
if (showMessageBtn) {
|
||||||
|
try {
|
||||||
|
await axiosInstance.post(`/vendors/${vendor.id}/increment_view_count/?search=1`);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
incrementVendorCount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{ p: 2 }}>
|
||||||
|
<Grid container sx={{ minHeight: '100%' }}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<Avatar
|
||||||
|
src={vendor.vendorImageUrl}
|
||||||
|
alt={vendor.name}
|
||||||
|
sx={{ width: 80, height: 80, mr: 2 }}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
{vendor.name}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Rating value={vendor.rating} readOnly precision={0.5} />
|
||||||
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||||
|
({vendor.rating.toFixed(1)} / 5)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{vendor.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
Contact Information
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
|
||||||
|
<PhoneIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.phone}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" display="flex" alignItems="center" mb={0.5}>
|
||||||
|
<EmailIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.email}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" display="flex" alignItems="center">
|
||||||
|
<LocationOnIcon fontSize="small" sx={{ mr: 1 }} /> {vendor.address}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
Services Offered
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
{vendor.servicesOffered.map((service, index) => (
|
||||||
|
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
|
||||||
|
- {service}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
Service Areas
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
{vendor.serviceAreas.map((service, index) => (
|
||||||
|
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
|
||||||
|
- {service}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{showMessageBtn && (
|
||||||
|
<Button onClick={createMessage} disabled={accountLoading}>
|
||||||
|
Message
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%', // Make sure the box takes available height
|
||||||
|
minHeight: '400px', // Ensure a minimum height for the map
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '4px', // Optional: rounded corners
|
||||||
|
overflow: 'hidden', // Ensures map doesn't overflow border radius
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{vendor.latitude && vendor.longitude ? (
|
||||||
|
<MapComponent lat={vendor.latitude} lng={vendor.longitude} address={vendor.address} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Location data not available for this vendor.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDetail;
|
||||||
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}
|
||||||
211
ditch-the-agent/src/contexts/WebSocketContext.tsx
Normal file
211
ditch-the-agent/src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect, createContext, useRef, useState, useContext, ReactNode } from 'react';
|
||||||
|
import { AccountContext } from './AccountContext';
|
||||||
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// Define Types and Interfaces
|
||||||
|
// ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the Account Context value.
|
||||||
|
* Assumes account has at least an email property.
|
||||||
|
*/
|
||||||
|
interface IAccount {
|
||||||
|
email: string | null;
|
||||||
|
// Add other properties of the account object as needed
|
||||||
|
// e.g., userId: string; name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
text: string;
|
||||||
|
sender: 'user' | 'ai';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the Auth Context value.
|
||||||
|
*/
|
||||||
|
interface IAuthContext {
|
||||||
|
authenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for the message data received from the WebSocket.
|
||||||
|
* Adjust 'any' to a more specific type if the message structure is known.
|
||||||
|
*/
|
||||||
|
type WebSocketMessageData = string; // Assuming message.data is a string (e.g., JSON string)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for a channel callback function.
|
||||||
|
*/
|
||||||
|
type ChannelCallback = (data: WebSocketMessageData) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the WebSocket Context value.
|
||||||
|
* This defines the shape of the array returned by `useContext(WebSocketContext)`.
|
||||||
|
*/
|
||||||
|
interface IWebSocketContext {
|
||||||
|
subscribe: (channel: string, callback: ChannelCallback) => void;
|
||||||
|
unsubscribe: (channel: string) => void;
|
||||||
|
socket: WebSocket | null;
|
||||||
|
sendMessages: (messages: ChatMessage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the WebSocketProvider component.
|
||||||
|
*/
|
||||||
|
interface WebSocketProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// Create the WebSocket Context
|
||||||
|
// ---
|
||||||
|
|
||||||
|
// Provide a default value that matches the IWebSocketContext interface.
|
||||||
|
// This is used when a component tries to consume the context without a provider.
|
||||||
|
const WebSocketContext = createContext<IWebSocketContext>({
|
||||||
|
subscribe: () => {},
|
||||||
|
unsubscribe: () => {},
|
||||||
|
socket: null,
|
||||||
|
sendMessages: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// WebSocket Provider Component
|
||||||
|
// ---
|
||||||
|
|
||||||
|
function WebSocketProvider({ children }: WebSocketProviderProps) {
|
||||||
|
// Using useContext with explicit types for better type checking
|
||||||
|
const { authenticated, loading } = useContext<IAuthContext>(AuthContext);
|
||||||
|
const { account, setAccount } = useContext<{
|
||||||
|
account: IAccount | null;
|
||||||
|
setAccount: React.Dispatch<React.SetStateAction<IAccount | null>>;
|
||||||
|
}>(AccountContext);
|
||||||
|
|
||||||
|
// useRef for WebSocket instance
|
||||||
|
const ws = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// useState for socket connection state
|
||||||
|
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// useRef to store channel callbacks, mapping channel names to their callbacks
|
||||||
|
const channels = useRef<{ [key: string]: ChannelCallback }>({});
|
||||||
|
|
||||||
|
// useState for the current active channel (though not directly used in the current logic for message dispatch)
|
||||||
|
const [currentChannel, setCurrentChannel] = useState<string>('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes a callback function to a specific WebSocket channel.
|
||||||
|
* @param channel The name of the channel to subscribe to.
|
||||||
|
* @param callback The function to be called when a message is received on this channel.
|
||||||
|
*/
|
||||||
|
const subscribe = (channel: string, callback: ChannelCallback) => {
|
||||||
|
setCurrentChannel(channel); // This seems to track the last subscribed channel globally, not per-message.
|
||||||
|
channels.current[channel] = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes a callback from a specific WebSocket channel.
|
||||||
|
* @param channel The name of the channel to unsubscribe from.
|
||||||
|
*/
|
||||||
|
const unsubscribe = (channel: string) => {
|
||||||
|
delete channels.current[channel];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message over the WebSocket connection.
|
||||||
|
* Handles both text messages and file uploads (by converting file to base64).
|
||||||
|
* @param message The text message to send.
|
||||||
|
* @param account_iod The ID of the conversation this message belongs to.
|
||||||
|
*/
|
||||||
|
const sendMessages = (messages: ChatMessage[]) => {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
const data = {
|
||||||
|
messages: messages,
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
console.log('Error sending message. WebSocket is not open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// WebSocket Initialization and Event Handling
|
||||||
|
// ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (account?.email) {
|
||||||
|
// Ensure account and email exist before attempting to connect
|
||||||
|
// Close any existing connection before creating a new one
|
||||||
|
if (ws.current) {
|
||||||
|
ws.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = new URL(import.meta.env.VITE_API_URL || 'ws://127.0.0.1:8010/ws/');
|
||||||
|
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
|
||||||
|
|
||||||
|
ws.current = new WebSocket(
|
||||||
|
`${wsUrl.origin}/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.current.onopen = () => {
|
||||||
|
setSocket(ws.current);
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onclose = () => {
|
||||||
|
setSocket(null);
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onmessage = (event: MessageEvent) => {
|
||||||
|
const data: WebSocketMessageData = event.data;
|
||||||
|
// The original logic assumes a single active chat channel is always present
|
||||||
|
// and iterates Object.entries(channels.current)[0][0] to get its key.
|
||||||
|
// This might be problematic if multiple channels are intended to be active
|
||||||
|
// or if messages don't always correspond to the first subscribed channel.
|
||||||
|
// For now, retaining the original logic, but consider refining based on backend message structure.
|
||||||
|
const chatChannelKey = Object.keys(channels.current)[0];
|
||||||
|
|
||||||
|
if (chatChannelKey && channels.current[chatChannelKey]) {
|
||||||
|
// Call the callback associated with the identified channel
|
||||||
|
channels.current[chatChannelKey](data);
|
||||||
|
} else {
|
||||||
|
console.log('No active chat channel subscribed or message format unexpected.');
|
||||||
|
// Potentially handle generic messages or log the unhandled message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup function: Closes the WebSocket connection when the component unmounts
|
||||||
|
// or when the 'account' dependency changes, triggering a re-run of this effect.
|
||||||
|
return () => {
|
||||||
|
if (ws.current) {
|
||||||
|
ws.current.close();
|
||||||
|
console.log('WebSocket cleaned up');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (!loading && !authenticated) {
|
||||||
|
// If not authenticated and not loading, ensure WebSocket is closed if it was open.
|
||||||
|
if (ws.current) {
|
||||||
|
ws.current.close();
|
||||||
|
ws.current = null;
|
||||||
|
setSocket(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [account, authenticated, loading]); // Dependencies: reconnect if account or auth status changes
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// Render the Provider
|
||||||
|
// ---
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider value={{ subscribe, unsubscribe, socket, sendMessages }}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WebSocketContext, WebSocketProvider };
|
||||||
56
ditch-the-agent/src/data/attorney-nav-items.ts
Normal file
56
ditch-the-agent/src/data/attorney-nav-items.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NavItem } from 'types';
|
||||||
|
|
||||||
|
const attorneyNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
path: '/',
|
||||||
|
icon: 'ion:home-sharp',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
path: '/',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Profile',
|
||||||
|
path: '/profile',
|
||||||
|
icon: 'ph:user-circle',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conversations',
|
||||||
|
path: '/conversations',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Documents',
|
||||||
|
path: '/documents',
|
||||||
|
icon: 'ph:file',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default attorneyNavItems;
|
||||||
112
ditch-the-agent/src/data/basic-nav-items.ts
Normal file
112
ditch-the-agent/src/data/basic-nav-items.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { NavItem } from 'types';
|
||||||
|
|
||||||
|
const basicNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
path: '/',
|
||||||
|
icon: 'ion:home-sharp',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
path: '/',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Profile',
|
||||||
|
path: '/profile',
|
||||||
|
icon: 'ph:user-circle',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Education',
|
||||||
|
path: '/upgrade',
|
||||||
|
icon: 'ph:student',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vendors',
|
||||||
|
path: '/upgrade',
|
||||||
|
icon: 'ph:storefront',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Messages',
|
||||||
|
path: '',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: false,
|
||||||
|
collapsible: true,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Documents',
|
||||||
|
path: 'documents',
|
||||||
|
icon: 'ph:folder',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conversations',
|
||||||
|
path: 'upgrade',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tools',
|
||||||
|
path: '/tools',
|
||||||
|
icon: 'ph:toolbox',
|
||||||
|
active: true,
|
||||||
|
collapsible: true,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Mortgage Calculator',
|
||||||
|
path: 'mortgage-calculator',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Amortization Table',
|
||||||
|
path: 'amoritization-table',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Home Affordability',
|
||||||
|
path: 'home-affordability',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Net Terms Sheet',
|
||||||
|
path: 'net-terms-sheet',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default basicNavItems;
|
||||||
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,93 +1,32 @@
|
|||||||
export interface NavItem {
|
import { NavItem } from 'types';
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
icon?: string;
|
|
||||||
active: boolean;
|
|
||||||
collapsible: boolean;
|
|
||||||
sublist?: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
path: '/',
|
path: '/dashboard',
|
||||||
icon: 'ion:home-sharp',
|
icon: 'ion:home-sharp',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
sublist: [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
path: '/',
|
path: '/dasboard',
|
||||||
active: false,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Sales',
|
|
||||||
path: '/',
|
|
||||||
active: false,
|
active: false,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Authentication',
|
title: 'Profile',
|
||||||
path: 'authentication',
|
path: '/profile',
|
||||||
icon: 'f7:exclamationmark-shield-fill',
|
icon: 'ph:user-circle',
|
||||||
active: true,
|
|
||||||
collapsible: true,
|
|
||||||
sublist: [
|
|
||||||
{
|
|
||||||
title: 'Sign In',
|
|
||||||
path: 'login',
|
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sign Up',
|
title: 'Search',
|
||||||
path: 'sign-up',
|
path: '/property-search',
|
||||||
active: true,
|
icon: 'ph:magnifying-glass',
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Forgot password',
|
|
||||||
path: 'forgot-password',
|
|
||||||
active: true,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Reset password',
|
|
||||||
path: 'reset-password',
|
|
||||||
active: true,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Notification',
|
|
||||||
path: '#!',
|
|
||||||
icon: 'zondicons:notifications',
|
|
||||||
active: false,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Calendar',
|
|
||||||
path: '#!',
|
|
||||||
icon: 'ph:calendar',
|
|
||||||
active: false,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Message',
|
|
||||||
path: '#!',
|
|
||||||
icon: 'ph:chat-circle-dots-fill',
|
|
||||||
active: false,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
title: 'Property',
|
|
||||||
path: '/property',
|
|
||||||
icon: 'ph:house-line',
|
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
@@ -101,11 +40,81 @@ const navItems: NavItem[] = [
|
|||||||
{
|
{
|
||||||
title: 'Vendors',
|
title: 'Vendors',
|
||||||
path: '/vendors',
|
path: '/vendors',
|
||||||
icon: 'ph:toolbox',
|
icon: 'ph:storefront',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Messages',
|
||||||
|
path: '',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: true,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Bids',
|
||||||
|
path: 'bids',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conversations',
|
||||||
|
path: 'conversations',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Documents',
|
||||||
|
path: 'documents',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tools',
|
||||||
|
path: '/tools',
|
||||||
|
icon: 'ph:toolbox',
|
||||||
|
active: true,
|
||||||
|
collapsible: true,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Mortgage Calculator',
|
||||||
|
path: 'mortgage-calculator',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Amortization Table',
|
||||||
|
path: 'amoritization-table',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Home Affordability',
|
||||||
|
path: 'home-affordability',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Net Terms Sheet',
|
||||||
|
path: 'net-terms-sheet',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default navItems;
|
export default navItems;
|
||||||
|
|||||||
13
ditch-the-agent/src/data/public-nav-items.ts
Normal file
13
ditch-the-agent/src/data/public-nav-items.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NavItem } from 'types';
|
||||||
|
|
||||||
|
const publicNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default publicNavItems;
|
||||||
56
ditch-the-agent/src/data/vendor-nav-items.ts
Normal file
56
ditch-the-agent/src/data/vendor-nav-items.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NavItem } from 'types';
|
||||||
|
|
||||||
|
const vendorNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
path: '/',
|
||||||
|
icon: 'ion:home-sharp',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
sublist: [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
path: '/',
|
||||||
|
active: false,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Profile',
|
||||||
|
path: '/profile',
|
||||||
|
icon: 'ph:user-circle',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conversations',
|
||||||
|
path: '/conversations',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bids',
|
||||||
|
path: '/vendor-bids',
|
||||||
|
icon: 'ph:chat-circle-dots',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default vendorNavItems;
|
||||||
@@ -4,7 +4,7 @@ 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}
|
||||||
@@ -14,7 +14,7 @@ const Footer = () => {
|
|||||||
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>
|
||||||
|
|||||||
@@ -29,15 +29,19 @@ const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
|||||||
setNestedChecked(updatedBooleanArray);
|
setNestedChecked(updatedBooleanArray);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
|
||||||
|
const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
|
||||||
|
const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
sx={{
|
sx={{
|
||||||
my: 1.25,
|
my: 1.25,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
backgroundColor: { backgroundColor },
|
||||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
color: { color },
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
backgroundColor: { hoverBackgroundColor },
|
||||||
opacity: 1.5,
|
opacity: 1.5,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user