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
@@ -1,48 +1,59 @@
|
||||
{
|
||||
"name": "mui-dta-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
||||
"deploy": "gh-pages -d dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/x-data-grid": "^7.2.0",
|
||||
"@mui/x-data-grid-generator": "^7.2.0",
|
||||
"axios": "^1.10.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"formik": "^2.4.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"material-ui-popup-state": "^5.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"simplebar-react": "^3.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^4.1.1",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "mui-dta-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:beta": "tsc && vite build --mode beta",
|
||||
"build:prod": "tsc && vite build --mode production",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
||||
"deploy": "gh-pages -d dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@mui/x-data-grid": "^7.2.0",
|
||||
"@mui/x-data-grid-generator": "^7.2.0",
|
||||
"@mui/x-date-pickers": "^8.11.2",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vis.gl/react-google-maps": "^1.5.4",
|
||||
"axios": "^1.10.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"formik": "^2.4.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"material-ui-popup-state": "^5.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"simplebar-react": "^3.2.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^4.1.1",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +1,105 @@
|
||||
import axios from "axios"
|
||||
import Cookies from 'js-cookie'
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
console.log(baseURL);
|
||||
|
||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
||||
//const baseURL = 'https://backend.ditchtheagent.com/api/';
|
||||
export const axiosRealEstateApi = axios.create({
|
||||
baseURL: 'https://api.realestateapi.com/v2/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': import.meta.env.REAL_ESTATE_API_KEY,
|
||||
'X-User-Id': 'UniqueUserIdentifier',
|
||||
},
|
||||
});
|
||||
|
||||
export const axiosWalkScoreApiInstance = axios.create({
|
||||
baseURL: 'https://api.walkscore.com/',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"Authorization": 'JWT ' + localStorage.getItem('access_token'),
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
Authorization: 'JWT ' + localStorage.getItem('access_token'),
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const cleanAxiosInstance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const axiosInstanceCSRF = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'X-CSRFToken': Cookies.get('csrftoken'), // Include CSRF token in headers
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use(config => {
|
||||
config.timeout = 100000;
|
||||
return config;
|
||||
})
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
config.timeout = 100000;
|
||||
return config;
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const originalRequest = error.config;
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Prevent infinite loop
|
||||
if (error.response.status === 401 && originalRequest.url === baseURL+'/token/refresh/') {
|
||||
window.location.href = '/signin/';
|
||||
//console.log('remove the local storage here')
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if(error.response.data.code === "token_not_valid" &&
|
||||
error.response.status == 401 &&
|
||||
error.response.statusText == 'Unauthorized')
|
||||
{
|
||||
const refresh_token = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refresh_token){
|
||||
const tokenParts = JSON.parse(atob(refresh_token.split('.')[1]));
|
||||
|
||||
const now = Math.ceil(Date.now() / 1000);
|
||||
//console.log(tokenParts.exp)
|
||||
|
||||
if(tokenParts.exp > now){
|
||||
return axiosInstance.post('/token/refresh/', {refresh: refresh_token}).then((response) => {
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
localStorage.setItem('refresh_token', response.data.refresh);
|
||||
|
||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
|
||||
return axiosInstance(originalRequest);
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
});
|
||||
|
||||
}else{
|
||||
console.log('Refresh token is expired');
|
||||
window.location.href = '/signin/';
|
||||
|
||||
}
|
||||
}else {
|
||||
console.log('Refresh token not available');
|
||||
window.location.href = '/signin/';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
return Promise.reject(error);
|
||||
// Prevent infinite loop
|
||||
if (error.response.status === 401 && originalRequest.url === baseURL + '/token/refresh/') {
|
||||
window.location.href = '/authentication/login/';
|
||||
//console.log('remove the local storage here')
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
error.response.data.code === 'token_not_valid' &&
|
||||
error.response.status == 401 &&
|
||||
error.response.statusText == 'Unauthorized'
|
||||
) {
|
||||
const refresh_token = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refresh_token) {
|
||||
const tokenParts = JSON.parse(atob(refresh_token.split('.')[1]));
|
||||
|
||||
const now = Math.ceil(Date.now() / 1000);
|
||||
//console.log(tokenParts.exp)
|
||||
|
||||
if (tokenParts.exp > now) {
|
||||
return axiosInstance
|
||||
.post('/token/refresh/', { refresh: refresh_token })
|
||||
.then((response) => {
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
localStorage.setItem('refresh_token', response.data.refresh);
|
||||
|
||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
originalRequest.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
} else {
|
||||
console.log('Refresh token is expired');
|
||||
window.location.href = '/authentication/login/';
|
||||
}
|
||||
} else {
|
||||
console.log('Refresh token not available');
|
||||
window.location.href = '/authentication/login/';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
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 { Box, Button, AppBar, Typography, useTheme, Fab, TextField, Paper, Toolbar, IconButton } from '@mui/material';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
AppBar,
|
||||
Typography,
|
||||
useTheme,
|
||||
Fab,
|
||||
TextField,
|
||||
Paper,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { ChatMessage, WebSocketContext } from 'contexts/WebSocketContext';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import FormattedListingText from './base/FormattedListingText';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -9,32 +30,26 @@ interface FloatingActionButtonProps {
|
||||
|
||||
const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
|
||||
return (
|
||||
|
||||
<Fab
|
||||
color="secondary"
|
||||
aria-label="open chat"
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
|
||||
right: 24, // Equivalent to Tailwind's right-6 (24px)
|
||||
zIndex: 50, // Equivalent to Tailwind's z-50
|
||||
boxShadow: '0px 10px 15px -3px rgba(0,0,0,0.1), 0px 4px 6px -2px rgba(0,0,0,0.05)', // Tailwind shadow-lg
|
||||
'&:hover': {
|
||||
backgroundColor: '#1d4ed8', // Blue-700 equivalent
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MessageSquareText size={24} />
|
||||
</Fab>
|
||||
<Fab
|
||||
color="secondary"
|
||||
aria-label="open chat"
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24, // Equivalent to Tailwind's bottom-6 (24px)
|
||||
right: 24, // Equivalent to Tailwind's right-6 (24px)
|
||||
zIndex: 50, // Equivalent to Tailwind's z-50
|
||||
boxShadow: '0px 10px 15px -3px rgba(0,0,0,0.1), 0px 4px 6px -2px rgba(0,0,0,0.05)', // Tailwind shadow-lg
|
||||
'&:hover': {
|
||||
backgroundColor: '#1d4ed8', // Blue-700 equivalent
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MessageSquareText size={24} />
|
||||
</Fab>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatMessage {
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
}
|
||||
|
||||
interface ChatPaneProps {
|
||||
showChat: boolean;
|
||||
isMinimized: boolean;
|
||||
@@ -42,7 +57,6 @@ interface ChatPaneProps {
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
|
||||
// Chat Pane Component
|
||||
const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPaneProps) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -52,8 +66,34 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
// Ref for the messages container to scroll to the bottom
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageRef = useRef('');
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const theme = useTheme();
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
|
||||
const { subscribe, unsubscribe, socket, sendMessages } = useContext(WebSocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountLoading) return;
|
||||
const channelName = `ACCOUNT_ID_${account?.email}`;
|
||||
subscribe(channelName, (message: string) => {
|
||||
if (message === 'BEGINING_OF_THE_WORLD') {
|
||||
} else if (message === 'END_OF_THE_WORLD') {
|
||||
const deepCopiedMessage = structuredClone(messageRef.current);
|
||||
setMessages((prevMessages) => [...prevMessages, { text: deepCopiedMessage, sender: 'ai' }]);
|
||||
messageRef.current = '';
|
||||
setCurrentMessage(messageRef.current);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
messageRef.current += message;
|
||||
setCurrentMessage(messageRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe(channelName);
|
||||
};
|
||||
}, [account, subscribe, unsubscribe]);
|
||||
// Scroll to the bottom of the chat window whenever messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -62,56 +102,73 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
// Function to send a message to the AI
|
||||
const handleSendMessage = async () => {
|
||||
if (inputMessage.trim() === '') return;
|
||||
|
||||
const newUserMessage: ChatMessage = { text: inputMessage, sender: 'user' };
|
||||
setMessages((prevMessages: ChatMessage[]) => [...prevMessages, newUserMessage]);
|
||||
setInputMessage(''); // Clear input field
|
||||
|
||||
setIsLoading(true); // Show loading indicator
|
||||
|
||||
try {
|
||||
// Construct chat history for the API call
|
||||
let chatHistory = messages.map(msg => ({
|
||||
role: msg.sender === 'user' ? 'user' : 'model',
|
||||
parts: [{ text: msg.text }]
|
||||
}));
|
||||
chatHistory.push({ role: "user", parts: [{ text: newUserMessage.text }] });
|
||||
|
||||
const payload = { contents: chatHistory };
|
||||
const apiKey = ""; // Leave this as-is; Canvas will provide the API key at runtime
|
||||
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.candidates && result.candidates.length > 0 &&
|
||||
result.candidates[0].content && result.candidates[0].content.parts &&
|
||||
result.candidates[0].content.parts.length > 0) {
|
||||
const aiResponseText = result.candidates[0].content.parts[0].text;
|
||||
setMessages((prevMessages) => [...prevMessages, { text: aiResponseText, sender: 'ai' }]);
|
||||
} else {
|
||||
// Handle cases where the response structure is unexpected or content is missing
|
||||
console.error("Unexpected API response structure:", result);
|
||||
setMessages((prevMessages) => [...prevMessages, { text: "Error: Could not get a response from AI.", sender: 'ai' }]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI response:', error);
|
||||
setMessages((prevMessages) => [...prevMessages, { text: "Error: Failed to connect to AI.", sender: 'ai' }]);
|
||||
} finally {
|
||||
setIsLoading(false); // Hide loading indicator
|
||||
if (account !== undefined) {
|
||||
const newMessage: ChatMessage = {
|
||||
text: inputMessage,
|
||||
sender: 'user',
|
||||
};
|
||||
sendMessages([...messages, newMessage]);
|
||||
setIsLoading(true);
|
||||
setMessages((prevMessages) => [...prevMessages, newMessage]);
|
||||
}
|
||||
|
||||
// const newUserMessage: ChatMessage = { text: inputMessage, sender: 'user' };
|
||||
// setMessages((prevMessages: ChatMessage[]) => [...prevMessages, newUserMessage]);
|
||||
// setInputMessage(''); // Clear input field
|
||||
|
||||
// setIsLoading(true); // Show loading indicator
|
||||
|
||||
// try {
|
||||
// // Construct chat history for the API call
|
||||
// let chatHistory = messages.map((msg) => ({
|
||||
// role: msg.sender === 'user' ? 'user' : 'model',
|
||||
// parts: [{ text: msg.text }],
|
||||
// }));
|
||||
// chatHistory.push({ role: 'user', parts: [{ text: newUserMessage.text }] });
|
||||
|
||||
// const payload = { contents: chatHistory };
|
||||
// const apiKey = ''; // Leave this as-is; Canvas will provide the API key at runtime
|
||||
// const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
|
||||
|
||||
// const response = await fetch(apiUrl, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(payload),
|
||||
// });
|
||||
|
||||
// const result = await response.json();
|
||||
|
||||
// if (
|
||||
// result.candidates &&
|
||||
// result.candidates.length > 0 &&
|
||||
// result.candidates[0].content &&
|
||||
// result.candidates[0].content.parts &&
|
||||
// result.candidates[0].content.parts.length > 0
|
||||
// ) {
|
||||
// const aiResponseText = result.candidates[0].content.parts[0].text;
|
||||
// setMessages((prevMessages) => [...prevMessages, { text: aiResponseText, sender: 'ai' }]);
|
||||
// } else {
|
||||
// // Handle cases where the response structure is unexpected or content is missing
|
||||
// console.error('Unexpected API response structure:', result);
|
||||
// setMessages((prevMessages) => [
|
||||
// ...prevMessages,
|
||||
// { text: 'Error: Could not get a response from AI.', sender: 'ai' },
|
||||
// ]);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Error fetching AI response:', error);
|
||||
// setMessages((prevMessages) => [
|
||||
// ...prevMessages,
|
||||
// { text: 'Error: Failed to connect to AI.', sender: 'ai' },
|
||||
// ]);
|
||||
// } finally {
|
||||
// setIsLoading(false); // Hide loading indicator
|
||||
// }
|
||||
};
|
||||
|
||||
if (!showChat) return null; // Don't render anything if chat is not shown
|
||||
|
||||
return (
|
||||
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
@@ -127,7 +184,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
zIndex: 40,
|
||||
width: isMinimized ? '320px' : '384px', // w-80 (320px) vs w-96 (384px)
|
||||
height: isMinimized ? '64px' : '485px', // h-16 (64px) vs h-[600px]
|
||||
'@media (min-width: 768px)': { // md: breakpoint
|
||||
'@media (min-width: 768px)': {
|
||||
// md: breakpoint
|
||||
height: isMinimized ? '64px' : 'calc(100vh - 130px)', // md:h-[calc(100vh-80px)]
|
||||
},
|
||||
border: '1px solid',
|
||||
@@ -135,12 +193,16 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
}}
|
||||
>
|
||||
{/* Chat Header */}
|
||||
<AppBar position="static" color='inherit' sx={{
|
||||
backgroundColor: 'background.paper'
|
||||
}}>
|
||||
|
||||
|
||||
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}> {/* minHeight to match h-16 for minimized */}
|
||||
<AppBar
|
||||
position="static"
|
||||
color="inherit"
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense" sx={{ justifyContent: 'space-between', minHeight: '64px' }}>
|
||||
{' '}
|
||||
{/* minHeight to match h-16 for minimized */}
|
||||
<Typography variant="h6" component="div">
|
||||
AI Assistant
|
||||
</Typography>
|
||||
@@ -148,15 +210,11 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={toggleMinimize}
|
||||
aria-label={isMinimized ? "Maximize chat" : "Minimize chat"}
|
||||
aria-label={isMinimized ? 'Maximize chat' : 'Minimize chat'}
|
||||
>
|
||||
<Minus size={20} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={closeChat}
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<IconButton color="inherit" onClick={closeChat} aria-label="Close chat">
|
||||
<X size={20} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -200,7 +258,8 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
borderBottomLeftRadius: msg.sender === 'user' ? '12px' : 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{msg.text}</Typography>
|
||||
{/*<Typography variant="body2">{msg.text}</Typography>*/}
|
||||
<FormattedListingText text={msg.text} />
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
@@ -218,9 +277,19 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>.</Typography>
|
||||
<Typography component="span" className="animate-bounce delay-100" sx={{ mr: 0.5 }}>.</Typography>
|
||||
<Typography component="span" className="animate-bounce delay-200">.</Typography>
|
||||
<Typography component="span" className="animate-bounce" sx={{ mr: 0.5 }}>
|
||||
.
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
className="animate-bounce delay-100"
|
||||
sx={{ mr: 0.5 }}
|
||||
>
|
||||
.
|
||||
</Typography>
|
||||
<Typography component="span" className="animate-bounce delay-200">
|
||||
.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
@@ -249,7 +318,9 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
size="small"
|
||||
value={inputMessage}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputMessage(e.target.value)}
|
||||
onKeyPress={(e: KeyboardEvent<HTMLInputElement>) => e.key === 'Enter' && handleSendMessage()}
|
||||
onKeyPress={(e: KeyboardEvent<HTMLInputElement>) =>
|
||||
e.key === 'Enter' && handleSendMessage()
|
||||
}
|
||||
placeholder="Type your message..."
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
@@ -321,51 +392,46 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
|
||||
`}
|
||||
</style>
|
||||
</Box>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
const FloatingChatButton = (): ReactElement =>
|
||||
{
|
||||
const [showChat, setShowChat] = useState<boolean>(false);
|
||||
// State to control if the chat pane is minimized
|
||||
const [isMinimized, setIsMinimized] = useState<boolean>(false);
|
||||
const FloatingChatButton = (): ReactElement => {
|
||||
const [showChat, setShowChat] = useState<boolean>(false);
|
||||
// State to control if the chat pane is minimized
|
||||
const [isMinimized, setIsMinimized] = useState<boolean>(false);
|
||||
|
||||
// Function to toggle the chat pane visibility
|
||||
const toggleChat = () => {
|
||||
setShowChat(!showChat);
|
||||
// When opening, ensure it's not minimized
|
||||
if (!showChat) {
|
||||
setIsMinimized(false);
|
||||
}
|
||||
};
|
||||
// Function to toggle the chat pane visibility
|
||||
const toggleChat = () => {
|
||||
setShowChat(!showChat);
|
||||
// When opening, ensure it's not minimized
|
||||
if (!showChat) {
|
||||
setIsMinimized(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle minimize/maximize the chat pane
|
||||
const toggleMinimize = () => {
|
||||
setIsMinimized(!isMinimized);
|
||||
};
|
||||
// Function to toggle minimize/maximize the chat pane
|
||||
const toggleMinimize = () => {
|
||||
setIsMinimized(!isMinimized);
|
||||
};
|
||||
|
||||
// Function to close the chat pane
|
||||
const closeChat = () => {
|
||||
setShowChat(false);
|
||||
setIsMinimized(false); // Reset minimize state when closing
|
||||
};
|
||||
return(
|
||||
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
|
||||
// Function to close the chat pane
|
||||
const closeChat = () => {
|
||||
setShowChat(false);
|
||||
setIsMinimized(false); // Reset minimize state when closing
|
||||
};
|
||||
return (
|
||||
<div className="relative h-screen w-full font-sans bg-gray-100 flex items-center justify-center">
|
||||
{/* Floating Action Button */}
|
||||
{!showChat && <FloatingActionButton onClick={toggleChat} />}
|
||||
|
||||
|
||||
<ChatPane
|
||||
showChat={showChat}
|
||||
isMinimized={isMinimized}
|
||||
toggleMinimize={toggleMinimize}
|
||||
closeChat={closeChat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default FloatingChatButton;
|
||||
export default FloatingChatButton;
|
||||
|
||||
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 { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { renderProgress } from '@mui/x-data-grid-generator';
|
||||
import { GridColDef } from '@mui/x-data-grid';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import { VideoProgressAPI } from 'types';
|
||||
|
||||
type EducationInfoProps = {
|
||||
title: string;
|
||||
interface CategoryProgress {
|
||||
categoryName: string;
|
||||
totalProgress: number;
|
||||
videoCount: number;
|
||||
averageProgress: number;
|
||||
}
|
||||
interface Row {
|
||||
id: number;
|
||||
task: string;
|
||||
progress: number; // Value from 0 to 100 for the progress bar
|
||||
}
|
||||
export const EducationInfoCards = () => {
|
||||
return(
|
||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
||||
<EducationInfo title={'Education'} />
|
||||
|
||||
interface EducationInfoCardProps {
|
||||
category: string;
|
||||
progress: number;
|
||||
totalVideos: number;
|
||||
completedVideos: number;
|
||||
}
|
||||
|
||||
const EducationInfoCard = ({
|
||||
category,
|
||||
progress,
|
||||
totalVideos,
|
||||
completedVideos,
|
||||
}: EducationInfoCardCardProps): ReactElement => {
|
||||
return (
|
||||
<Card sx={{ boxShadow: 4, width: '100%' }}>
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6" component="h2">
|
||||
{category}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{ height: 8, borderRadius: 5 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{completedVideos} of {totalVideos} videos complete
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID'
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'category',
|
||||
headerName: 'Category',
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
{
|
||||
field: 'progress',
|
||||
headerName: 'Progress',
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams<Row, number>) => {
|
||||
const progressValue = params.value; // Access the progress value from the row data
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<LinearProgress variant="determinate" value={progressValue} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 35 }}>
|
||||
<Typography variant="body2" color="text.secondary">{`${progressValue}%`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
title: "How to Research Comparable Properties Like a Pro",
|
||||
category: "Pricing Strategy",
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Understanding Price Per Square Foot in Your Neighborhood",
|
||||
category: "Pricing Strategy",
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Psychological Pricing: Why $399,900 Works Better Than $400,000",
|
||||
category: "Pricing Strategy",
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "When and How to Adjust Your Asking Price",
|
||||
category: "Pricing Strategy",
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Handling Lowball Offers: Strategies That Work",
|
||||
category: "Pricing Strategy",
|
||||
progress: 100,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{
|
||||
id: 6,
|
||||
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
|
||||
category: "Property Preparation",
|
||||
progress: 90,
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "DIY Curb Appeal Upgrades Under $500",
|
||||
category: "Property Preparation",
|
||||
progress: 80,
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Decluttering Secrets for Faster Sales",
|
||||
category: "Property Preparation",
|
||||
progress: 5,
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Professional Photography Tips Using Just Your Smartphone",
|
||||
category: "Property Preparation",
|
||||
progress: 50,
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Deep Cleaning Checklist Before Listing",
|
||||
category: "Property Preparation",
|
||||
progress: 50,
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "How to stage a home",
|
||||
category: "",
|
||||
progress: 0,
|
||||
status: "NOT_STARTED",
|
||||
},
|
||||
]
|
||||
return(
|
||||
<Card
|
||||
sx={(theme) => ({
|
||||
boxShadow: theme.shadows[4],
|
||||
width: 1,
|
||||
height: 'auto',
|
||||
})}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
padding: 0,
|
||||
':last-child': {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
|
||||
<Stack
|
||||
bgcolor="background.paper"
|
||||
borderRadius={5}
|
||||
width={1}
|
||||
boxShadow={(theme) => theme.shadows[4]}
|
||||
height={1}
|
||||
>
|
||||
<DataGrid
|
||||
getRowHeight={() => 70}
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
onRowClick={(event) => navigate('lesson')}
|
||||
/>
|
||||
|
||||
|
||||
</Stack>
|
||||
|
||||
|
||||
|
||||
</CardContent>
|
||||
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
export const EducationInfoCards = () => {
|
||||
const [videoProgressData, setVideoProgressData] = useState<VideoProgressAPI[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
export default EducationInfo;
|
||||
useEffect(() => {
|
||||
// This is a mock function. Replace with your actual API call.
|
||||
const fetchVideoProgress = async (): Promise<VideoProgressAPI[]> => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(`/videos/progress/`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchVideoProgress();
|
||||
setVideoProgressData(data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch video progress data.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const getCategoryProgress = () => {
|
||||
if (!videoProgressData) return {};
|
||||
|
||||
const categories: { [key: string]: CategoryProgress } = {};
|
||||
|
||||
videoProgressData.forEach((item) => {
|
||||
// Access the category name from the nested object
|
||||
const categoryName = item.video.category?.name;
|
||||
if (!categoryName) return; // Skip items without a category name
|
||||
|
||||
if (!categories[categoryName]) {
|
||||
categories[categoryName] = {
|
||||
categoryName: categoryName,
|
||||
totalProgress: 0,
|
||||
videoCount: 0,
|
||||
averageProgress: 0,
|
||||
};
|
||||
}
|
||||
categories[categoryName].totalProgress += item.progress;
|
||||
categories[categoryName].videoCount++;
|
||||
});
|
||||
|
||||
// Calculate average progress for each category
|
||||
for (const key in categories) {
|
||||
const categoryInfo = categories[key];
|
||||
categoryInfo.averageProgress = Math.round(
|
||||
categoryInfo.totalProgress / categoryInfo.videoCount,
|
||||
);
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const categoryProgressData = getCategoryProgress();
|
||||
|
||||
if (loading) {
|
||||
return <Typography>Loading progress...</Typography>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
}
|
||||
if (videoProgressData.length === 0) {
|
||||
return <Typography>There are no videos yet</Typography>;
|
||||
} else {
|
||||
return (
|
||||
<Stack
|
||||
direction={{ sm: 'row' }}
|
||||
justifyContent={{ sm: 'space-between' }}
|
||||
gap={3.75}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{Object.values(categoryProgressData).map((data, index) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
|
||||
<EducationInfoCard
|
||||
category={data.categoryName}
|
||||
progress={data.averageProgress}
|
||||
totalVideos={data.videoCount}
|
||||
completedVideos={
|
||||
videoProgressData.filter(
|
||||
(item) =>
|
||||
item.video.category?.name === data.categoryName && item.progress === 100,
|
||||
).length
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 { Card, CardContent, CardMedia, Stack, Typography } from '@mui/material';
|
||||
import { Button, Card, CardActions, CardContent, CardMedia, Stack, Typography } from '@mui/material';
|
||||
import { PropertiesAPI } from 'types';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type EducationInfoProps = {
|
||||
title: string;
|
||||
property: PropertiesAPI;
|
||||
}
|
||||
|
||||
export const ProperyInfoCards = () => {
|
||||
export const ProperyInfoCards = ({ property }: EducationInfoProps) => {
|
||||
return(
|
||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
||||
<PropertyInfo title={'1968 Greensboro Dr'} />
|
||||
<PropertyInfo property={property} />
|
||||
|
||||
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
const PropertyInfo = ({ property }: EducationInfoProps): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const estimated_savings = Number(property.market_value) * 0.06 - 6000
|
||||
return(
|
||||
<Card
|
||||
sx={(theme) => ({
|
||||
@@ -25,7 +30,38 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
height: 'auto',
|
||||
})}
|
||||
>
|
||||
<CardContent
|
||||
<CardMedia
|
||||
sx={{ height: 140 }}
|
||||
image="https://saterdesign.com/cdn/shop/files/9024-Main-Image.jpg"
|
||||
title="green iguana"
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{property.address}
|
||||
</Typography>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
${property.market_value}
|
||||
</Typography>
|
||||
<Stack direction='row'>
|
||||
<Typography variant='caption'>
|
||||
3 bd | 2 ba | 1,860 sqtf
|
||||
</Typography>
|
||||
<Typography variant='caption'>
|
||||
Listed 14 days ago
|
||||
</Typography>
|
||||
|
||||
</Stack>
|
||||
|
||||
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button size="small"
|
||||
variant="contained"
|
||||
component="label"
|
||||
onClick={() => navigate('/property')}>View</Button>
|
||||
</CardActions>
|
||||
{/* <CardContent
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
padding: 0,
|
||||
@@ -36,16 +72,16 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
||||
{title}
|
||||
{property.address}
|
||||
</Typography>
|
||||
|
||||
</Stack>
|
||||
<Stack direction="column" justifyContent="space-between" flexWrap="wrap">
|
||||
<Typography>
|
||||
Estimated Home Value: <b>$700,500k</b>
|
||||
Estimated Home Value: <b>${property.market_value}</b>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Estimated Savings: $24,000k
|
||||
Estimated Savings: ${estimated_savings}
|
||||
</Typography>
|
||||
<Typography>
|
||||
Compariable Time on market: 5 days
|
||||
@@ -57,7 +93,7 @@ const PropertyInfo = ({ title }: EducationInfoProps): ReactElement => {
|
||||
|
||||
|
||||
|
||||
</CardContent>
|
||||
</CardContent> */}
|
||||
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,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,111 +1,120 @@
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
active: boolean;
|
||||
collapsible: boolean;
|
||||
sublist?: NavItem[];
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Sales',
|
||||
path: '/',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
path: 'authentication',
|
||||
icon: 'f7:exclamationmark-shield-fill',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Sign In',
|
||||
path: 'login',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Sign Up',
|
||||
path: 'sign-up',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Forgot password',
|
||||
path: 'forgot-password',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Reset password',
|
||||
path: 'reset-password',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notification',
|
||||
path: '#!',
|
||||
icon: 'zondicons:notifications',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Calendar',
|
||||
path: '#!',
|
||||
icon: 'ph:calendar',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
path: '#!',
|
||||
icon: 'ph:chat-circle-dots-fill',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Property',
|
||||
path: '/property',
|
||||
icon: 'ph:house-line',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Education',
|
||||
path: '/education',
|
||||
icon: 'ph:student',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Vendors',
|
||||
path: '/vendors',
|
||||
icon: 'ph:toolbox',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
export default navItems;
|
||||
import { NavItem } from 'types';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/dashboard',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/dasboard',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Profile',
|
||||
path: '/profile',
|
||||
icon: 'ph:user-circle',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property-search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Education',
|
||||
path: '/education',
|
||||
icon: 'ph:student',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Vendors',
|
||||
path: '/vendors',
|
||||
icon: 'ph:storefront',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Messages',
|
||||
path: '',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: 'conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Documents',
|
||||
path: 'documents',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
path: '/tools',
|
||||
icon: 'ph:toolbox',
|
||||
active: true,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Mortgage Calculator',
|
||||
path: 'mortgage-calculator',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Amortization Table',
|
||||
path: 'amoritization-table',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Home Affordability',
|
||||
path: 'home-affordability',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Net Terms Sheet',
|
||||
path: 'net-terms-sheet',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
path: '/support',
|
||||
icon: 'ph:question',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default navItems;
|
||||
|
||||
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;
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Link, Stack, Typography } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={{ xs: 'center', md: 'flex-end' }}
|
||||
ml={{ xs: 3.75, lg: 34.75 }}
|
||||
mr={3.75}
|
||||
my={3.75}
|
||||
>
|
||||
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
||||
<Link
|
||||
href="https://ditchtheagent.com/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ color: 'text.primary', '&:hover': { color: 'primary.main' } }}
|
||||
>
|
||||
Ditch The Agent
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
import { Link, Stack, Typography } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={{ xs: 'center' }}
|
||||
ml={{ xs: 3.75, lg: 34.75 }}
|
||||
mr={3.75}
|
||||
my={3.75}
|
||||
>
|
||||
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
||||
<Link
|
||||
href="https://ditchtheagent.com/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
|
||||
>
|
||||
Ditch The Agent
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,149 +1,153 @@
|
||||
import { ReactElement, useState } from 'react';
|
||||
import {
|
||||
Collapse,
|
||||
LinkTypeMap,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { NavItem } from 'data/nav-items';
|
||||
|
||||
interface NavItemProps {
|
||||
navItem: NavItem;
|
||||
Link: OverridableComponent<LinkTypeMap>;
|
||||
}
|
||||
|
||||
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
||||
const { pathname } = useLocation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
|
||||
|
||||
const handleNestedChecked = (index: any, value: boolean) => {
|
||||
const updatedBooleanArray = [...nestedChecked];
|
||||
updatedBooleanArray[index] = value;
|
||||
setNestedChecked(updatedBooleanArray);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
my: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
||||
opacity: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{navItem.collapsible ? (
|
||||
<>
|
||||
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
|
||||
<ListItemIcon>
|
||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{navItem.title}</ListItemText>
|
||||
<ListItemIcon>
|
||||
{navItem.collapsible &&
|
||||
(checked ? (
|
||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||
) : (
|
||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||
))}
|
||||
</ListItemIcon>
|
||||
</ListItemButton>
|
||||
<Collapse in={checked}>
|
||||
<List>
|
||||
{navItem.sublist?.map((subListItem: any, idx: number) => (
|
||||
<ListItem
|
||||
key={idx}
|
||||
sx={{
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
||||
opacity: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{subListItem.collapsible ? (
|
||||
<>
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
onClick={() => {
|
||||
handleNestedChecked(idx, !nestedChecked[idx]);
|
||||
}}
|
||||
>
|
||||
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
|
||||
<ListItemIcon>
|
||||
{subListItem.collapsible &&
|
||||
(nestedChecked[idx] ? (
|
||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||
) : (
|
||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||
))}
|
||||
</ListItemIcon>
|
||||
</ListItemButton>
|
||||
<Collapse in={nestedChecked[idx]}>
|
||||
<List>
|
||||
{subListItem?.sublist?.map(
|
||||
(nestedSubListItem: any, nestedIdx: number) => (
|
||||
<ListItem key={nestedIdx}>
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={
|
||||
navItem.path !== '/'
|
||||
? navItem.path +
|
||||
'/' +
|
||||
subListItem.path +
|
||||
'/' +
|
||||
nestedSubListItem.path
|
||||
: nestedSubListItem.path
|
||||
}
|
||||
>
|
||||
<ListItemText sx={{ ml: 5 }}>
|
||||
{nestedSubListItem.title}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={navItem.path + '/' + subListItem.path}
|
||||
>
|
||||
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={navItem.path}
|
||||
sx={{ opacity: navItem.active ? 1 : 0.6 }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{navItem.title}</ListItemText>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavButton;
|
||||
import { ReactElement, useState } from 'react';
|
||||
import {
|
||||
Collapse,
|
||||
LinkTypeMap,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { NavItem } from 'data/nav-items';
|
||||
|
||||
interface NavItemProps {
|
||||
navItem: NavItem;
|
||||
Link: OverridableComponent<LinkTypeMap>;
|
||||
}
|
||||
|
||||
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
||||
const { pathname } = useLocation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
|
||||
|
||||
const handleNestedChecked = (index: any, value: boolean) => {
|
||||
const updatedBooleanArray = [...nestedChecked];
|
||||
updatedBooleanArray[index] = value;
|
||||
setNestedChecked(updatedBooleanArray);
|
||||
};
|
||||
|
||||
const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
|
||||
const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
|
||||
const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
my: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: { backgroundColor },
|
||||
color: { color },
|
||||
'&:hover': {
|
||||
backgroundColor: { hoverBackgroundColor },
|
||||
opacity: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{navItem.collapsible ? (
|
||||
<>
|
||||
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
|
||||
<ListItemIcon>
|
||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{navItem.title}</ListItemText>
|
||||
<ListItemIcon>
|
||||
{navItem.collapsible &&
|
||||
(checked ? (
|
||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||
) : (
|
||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||
))}
|
||||
</ListItemIcon>
|
||||
</ListItemButton>
|
||||
<Collapse in={checked}>
|
||||
<List>
|
||||
{navItem.sublist?.map((subListItem: any, idx: number) => (
|
||||
<ListItem
|
||||
key={idx}
|
||||
sx={{
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
||||
opacity: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{subListItem.collapsible ? (
|
||||
<>
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
onClick={() => {
|
||||
handleNestedChecked(idx, !nestedChecked[idx]);
|
||||
}}
|
||||
>
|
||||
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
|
||||
<ListItemIcon>
|
||||
{subListItem.collapsible &&
|
||||
(nestedChecked[idx] ? (
|
||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||
) : (
|
||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||
))}
|
||||
</ListItemIcon>
|
||||
</ListItemButton>
|
||||
<Collapse in={nestedChecked[idx]}>
|
||||
<List>
|
||||
{subListItem?.sublist?.map(
|
||||
(nestedSubListItem: any, nestedIdx: number) => (
|
||||
<ListItem key={nestedIdx}>
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={
|
||||
navItem.path !== '/'
|
||||
? navItem.path +
|
||||
'/' +
|
||||
subListItem.path +
|
||||
'/' +
|
||||
nestedSubListItem.path
|
||||
: nestedSubListItem.path
|
||||
}
|
||||
>
|
||||
<ListItemText sx={{ ml: 5 }}>
|
||||
{nestedSubListItem.title}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={navItem.path + '/' + subListItem.path}
|
||||
>
|
||||
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<ListItemButton
|
||||
LinkComponent={Link}
|
||||
href={navItem.path}
|
||||
sx={{ opacity: navItem.active ? 1 : 0.6 }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{navItem.title}</ListItemText>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavButton;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user