inital checkin before beta launch
This commit is contained in:
@@ -1 +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
|
||||
904
ditch-the-agent/package-lock.json
generated
904
ditch-the-agent/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
"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",
|
||||
@@ -14,15 +16,20 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@mui/x-data-grid": "^7.2.0",
|
||||
"@mui/x-data-grid-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",
|
||||
@@ -31,7 +38,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"simplebar-react": "^3.2.5"
|
||||
"simplebar-react": "^3.2.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^4.1.1",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
||||
//const baseURL = 'https://backend.ditchtheagent.com/api/';
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
console.log(baseURL);
|
||||
|
||||
export const axiosRealEstateApi = axios.create({
|
||||
baseURL: 'https://api.realestateapi.com/v2/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70',
|
||||
'X-API-Key': import.meta.env.REAL_ESTATE_API_KEY,
|
||||
'X-User-Id': 'UniqueUserIdentifier',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -18,7 +17,7 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
{categories.map((category) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={category.id}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={category.id}>
|
||||
{renderCategoryCard(category, onSelectCategory)}
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
// 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 {
|
||||
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> {
|
||||
interface ItemListDetailTemplateProps<
|
||||
TCategory extends GenericCategory,
|
||||
TItem extends GenericItem,
|
||||
> {
|
||||
category: TCategory;
|
||||
items: TItem[];
|
||||
onBack: () => void;
|
||||
renderListItem: (item: TItem, isSelected: boolean, onSelect: (itemId: string) => void) => ReactNode;
|
||||
renderListItem: (
|
||||
item: TItem,
|
||||
isSelected: boolean,
|
||||
onSelect: (itemId: string) => void,
|
||||
) => ReactNode;
|
||||
renderItemDetail: (item: TItem) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -22,21 +40,19 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
|
||||
|
||||
// Default to the first item in the list
|
||||
let temp = null
|
||||
let temp = null;
|
||||
useEffect(() => {
|
||||
|
||||
if (items.length > 0) {
|
||||
temp = items[0].id
|
||||
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)
|
||||
console.log(selectedItemId, selectedItem);
|
||||
|
||||
const handleItemSelect = (itemId: string) => {
|
||||
setSelectedItemId(itemId);
|
||||
@@ -44,14 +60,15 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<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}}>
|
||||
<IconButton size="small" color="inherit" onClick={onBack} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" gutterBottom>{category.name} List</Typography>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{category.name} List
|
||||
</Typography>
|
||||
</Stack>
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
@@ -62,12 +79,23 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<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
|
||||
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>
|
||||
|
||||
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;
|
||||
@@ -1,25 +1,36 @@
|
||||
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
|
||||
|
||||
import { ReactElement} from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
const LoadingSkeleton = (): ReactElement => {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
</Container>
|
||||
)
|
||||
|
||||
}
|
||||
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;
|
||||
@@ -30,7 +30,7 @@ const VendorBidsPage: React.FC = () => {
|
||||
</Typography>
|
||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||
{bids.map((bid) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={bid.id}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={bid.id}>
|
||||
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -80,7 +80,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{/* Active Cases Card */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -113,7 +113,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Upcoming Deadlines Card */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -141,7 +141,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Documents to Review Card */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { PropertiesAPI, UserAPI } from 'types';
|
||||
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
//==import Grid from '@mui/material/Unstable_Grid2';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
@@ -26,6 +27,7 @@ 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;
|
||||
@@ -35,6 +37,9 @@ 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(() => {
|
||||
@@ -69,16 +74,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
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 = (updatedProperty: PropertiesAPI) => {
|
||||
console.log('handle save. IMPLEMENT ME');
|
||||
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 = (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
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[] = [
|
||||
@@ -122,6 +183,8 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
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
|
||||
@@ -201,11 +264,38 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</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
|
||||
@@ -221,80 +311,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
<Grid xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
<Grid xs={12} md={6}>
|
||||
|
||||
<Grid xs={12} md={documentsCardLength}>
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Documents Requiring Attention</Typography>
|
||||
<Typography variant="caption">something</Typography>
|
||||
</Stack>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
|
||||
</CardContent>
|
||||
{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={6}>
|
||||
<Grid xs={12} md={savedPropertiesCardLength}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Video Progress</Typography>
|
||||
<Typography variant="caption">
|
||||
Complete our FSBO training to maximize your sale potential
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Saved Properties</Typography>
|
||||
<Typography variant="caption">Keep track of the properties you have saved</Typography>
|
||||
</Stack>
|
||||
|
||||
<CardContent>
|
||||
<EducationInfoCards />
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<SavedPropertiesTable savedProperties={savedProperties} />
|
||||
</CardContent>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Upgrade your account</Typography>
|
||||
<Typography variant="caption">
|
||||
Unlock premium features to get more features and sell faster
|
||||
</Typography>
|
||||
</Stack>
|
||||
<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>
|
||||
|
||||
<CardActionArea>
|
||||
<Button variant="contained" component="label">
|
||||
Upgrade
|
||||
</Button>
|
||||
<Button>Learn More</Button>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* <Grid xs={12} md={4}>
|
||||
<NotificationInfoCard />
|
||||
</Grid>
|
||||
<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>
|
||||
|
||||
<Grid xs={12} md={8}>
|
||||
<EducationInfoCards />
|
||||
<CardActionArea>
|
||||
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
|
||||
Upgrade
|
||||
</Button>
|
||||
<Button>Learn More</Button>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<SaleInfoCards />
|
||||
</Grid>
|
||||
<Grid xs={12} md={8}>
|
||||
<Revenue />
|
||||
</Grid>
|
||||
<Grid xs={12} md={4}>
|
||||
<WebsiteVisitors />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={8}>
|
||||
<TopSellingProduct />
|
||||
</Grid>
|
||||
<Grid xs={12} lg={4}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
|
||||
gap={3.75}
|
||||
height={1}
|
||||
width={1}
|
||||
>
|
||||
<NewCustomers />
|
||||
<BuyersProfile />
|
||||
</Stack>
|
||||
</Grid> */}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{/* Listings Summary Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -83,7 +83,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
||||
</Grid>
|
||||
|
||||
{/* New Offers Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -114,7 +114,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
||||
</Grid>
|
||||
|
||||
{/* Upcoming Showings Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -148,7 +148,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
||||
</Grid>
|
||||
|
||||
{/* Example of other cards */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
|
||||
@@ -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;
|
||||
@@ -165,7 +165,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{/* Views Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -189,7 +189,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Grid>
|
||||
|
||||
{/* Bids Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -240,7 +240,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Grid>
|
||||
|
||||
{/* Conversations Card */}
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
@@ -278,10 +278,10 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>*/}
|
||||
<Grid xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Grid size={{ xs: 12, md: 12 }}>
|
||||
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -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;
|
||||
@@ -13,7 +13,7 @@ const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategor
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
{categories.map((category) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={category.name}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={category.name}>
|
||||
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -1,205 +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
|
||||
|
||||
interface EducationInfoCardProps {
|
||||
category: string;
|
||||
progress: number;
|
||||
totalVideos: number;
|
||||
completedVideos: number;
|
||||
}
|
||||
export const EducationInfoCards = () => {
|
||||
return(
|
||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
||||
<EducationInfo title={'Education'} />
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
// src/components/VideoApp/VideoCategoryCard.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardMedia, Typography, Button, LinearProgress, Box } from '@mui/material';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { VideoCategory } from 'types';
|
||||
|
||||
|
||||
|
||||
interface VideoCategoryCardProps {
|
||||
category: VideoCategory;
|
||||
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
|
||||
}
|
||||
|
||||
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||
console.log(category)
|
||||
return (
|
||||
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={category.imageUrl}
|
||||
alt={category.name}
|
||||
/>
|
||||
<CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{category.name}
|
||||
@@ -28,9 +28,20 @@ const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelec
|
||||
{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>
|
||||
<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 }}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 { axiosInstance } from '../../../../../axiosApi';
|
||||
import { VideoItem, VideoProgressAPI } from 'types';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import PauseIcon from '@mui/icons-material/Pause';
|
||||
@@ -20,21 +20,33 @@ interface VideoProgress {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
|
||||
interface VideoPlayerProps {
|
||||
video: VideoItem;
|
||||
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
const {account, accountLoading} = useContext(AccountContext);
|
||||
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
|
||||
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);
|
||||
@@ -47,37 +59,41 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
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 saveProgress = useCallback(
|
||||
async (time: number, completed: boolean = false) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const progressData: VideoProgress = {
|
||||
const progressData: VideoProgress = {
|
||||
progress: Math.round(time),
|
||||
};
|
||||
|
||||
progress: Math.round(time),
|
||||
|
||||
};
|
||||
|
||||
try {
|
||||
// First, try to fetch existing progress
|
||||
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`);
|
||||
if (response.data.length > 0) {
|
||||
// If progress exists, update it
|
||||
const existingProgress = response.data[0];
|
||||
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
|
||||
} else {
|
||||
// If no progress, create a new one
|
||||
await axiosInstance.post('/videos/progress/', progressData);
|
||||
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.');
|
||||
}
|
||||
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]);
|
||||
},
|
||||
[video.id, account?.id],
|
||||
);
|
||||
|
||||
// Fetch initial progress when video changes or component mounts
|
||||
useEffect(() => {
|
||||
@@ -85,14 +101,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const {data,} = await axiosInstance.get<VideoProgressAPI>(`/videos/progress/${video.id}/?user=${account?.id}`);
|
||||
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) {
|
||||
@@ -128,7 +145,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
};
|
||||
}, [video.id, account?.id, saveProgress]);
|
||||
|
||||
|
||||
// Video event handlers
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
@@ -148,7 +164,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(e => console.error("Error playing video:", e));
|
||||
videoRef.current.play().catch((e) => console.error('Error playing video:', e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +186,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
// 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));
|
||||
videoRef.current.play().catch((e) => console.error('Error resuming video:', e));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
@@ -195,7 +211,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.current.requestFullscreen().catch(err => {
|
||||
videoRef.current.requestFullscreen().catch((err) => {
|
||||
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||
});
|
||||
} else {
|
||||
@@ -227,18 +243,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
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}
|
||||
<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={{
|
||||
@@ -250,13 +271,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
backgroundColor: '#000',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
|
||||
|
||||
>
|
||||
<Typography>
|
||||
Your browser does nto support the video tag.
|
||||
</Typography>
|
||||
</video>
|
||||
>
|
||||
<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' }}>
|
||||
@@ -276,12 +293,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
||||
</Box>
|
||||
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
|
||||
<IconButton onClick={toggleFullScreen} color="primary" size="large">
|
||||
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />}
|
||||
{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}
|
||||
Current Progress: {Math.round((video.progress / video.duration) * 100)}% - Status:{' '}
|
||||
{video.status}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
@@ -101,7 +101,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
@@ -285,7 +285,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
<DialogTitle>Add New Property</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
@@ -319,7 +319,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
@@ -331,7 +331,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
@@ -343,7 +343,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
@@ -355,7 +355,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
@@ -366,7 +366,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Square Footage"
|
||||
@@ -378,7 +378,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.sq_ft}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="# Bedrooms"
|
||||
@@ -390,7 +390,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.num_bedrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="# Bathrooms"
|
||||
@@ -402,7 +402,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
helperText={formErrors.num_bathrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Features (comma-separated)"
|
||||
@@ -416,7 +416,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Market Value"
|
||||
@@ -425,7 +425,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Amount"
|
||||
@@ -434,7 +434,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Term (years)"
|
||||
@@ -444,7 +444,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Start Date"
|
||||
@@ -455,7 +455,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Button variant="contained" component="label">
|
||||
Upload Pictures
|
||||
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
|
||||
@@ -472,7 +472,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
</Box>
|
||||
</Grid>
|
||||
{newProperty.latitude && newProperty.longitude && (
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<MapComponent
|
||||
lat={newProperty.latitude}
|
||||
lng={newProperty.longitude}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, ReactElement } from 'react';
|
||||
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
|
||||
|
||||
import { AttorneyAPI, UserAPI } from '../types/api';
|
||||
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';
|
||||
@@ -111,6 +112,10 @@ const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
onUpgrade={handleUpgradeSubscription}
|
||||
onSave={handleSaveAttorneyProfile}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<ChangePasswordCard />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
@@ -243,7 +243,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
@@ -253,7 +253,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
@@ -263,7 +263,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
@@ -274,7 +274,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Firm Name"
|
||||
@@ -287,7 +287,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.firm_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Phone Number"
|
||||
@@ -300,7 +300,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.phone_number}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
@@ -345,7 +345,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.address}
|
||||
/>*/}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
@@ -358,7 +358,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
@@ -371,7 +371,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
@@ -399,7 +399,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
))}
|
||||
</Box>
|
||||
</Grid>*/}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Years of Experience"
|
||||
@@ -412,7 +412,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
helperText={formErrors.years_experience}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Licensed States (comma-separated)"
|
||||
@@ -427,7 +427,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
@@ -437,7 +437,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Biography"
|
||||
@@ -449,7 +449,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
@@ -460,7 +460,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
@@ -477,7 +477,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
||||
)}
|
||||
</Grid>
|
||||
{editedAttorney.latitude && editedAttorney.longitude && (
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Firm Location on Map:
|
||||
</Typography>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,28 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, TextField, Button, Box, Alert } from '@mui/material';
|
||||
import { Card, CardContent, Typography, TextField, Button, Alert, Tooltip } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface OfferSubmissionCardProps {
|
||||
onOfferSubmit: (offerAmount: number) => void;
|
||||
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 = () => {
|
||||
const handleSubmit = async () => {
|
||||
const amount = parseFloat(offerAmount);
|
||||
if (amount > 0) {
|
||||
onOfferSubmit(amount);
|
||||
setSubmitted(true);
|
||||
setTimeout(() => setSubmitted(false), 5000);
|
||||
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>
|
||||
@@ -36,8 +105,38 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
||||
value={offerAmount}
|
||||
onChange={(e) => setOfferAmount(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
helperText={
|
||||
offerPercentage > 0
|
||||
? `This offer is ${offerPercentage.toFixed(2)}% of the listing price.`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<Button variant="contained" color="primary" fullWidth onClick={handleSubmit}>
|
||||
<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 && (
|
||||
@@ -45,6 +144,11 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
||||
Your offer of ${offerAmount} has been submitted!
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Divider } from '@mui/material';
|
||||
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;
|
||||
openHouses: OpenHouseAPI[] | undefined;
|
||||
}
|
||||
|
||||
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
|
||||
if(openHouses){
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Open House Information
|
||||
</Typography>
|
||||
{openHouses.length > 0 ? (
|
||||
<List dense>
|
||||
{openHouses.map((openHouse, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`${openHouse.date} at ${openHouse.time}`}
|
||||
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < openHouses.length - 1 && <Divider component="li" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No upcoming open houses scheduled.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
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>
|
||||
} 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;
|
||||
@@ -20,9 +20,15 @@ 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 }) => {
|
||||
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 }>({});
|
||||
@@ -39,18 +45,23 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
||||
console.log(editedUser);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEditedUser((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
// Clear error for the field being edited
|
||||
if (formErrors[name]) {
|
||||
setFormErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,31 +112,29 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
name="first_name"
|
||||
value={editedUser.first_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.first_name}
|
||||
helperText={formErrors.first_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
name="last_name"
|
||||
value={editedUser.last_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.last_name}
|
||||
helperText={formErrors.last_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
@@ -133,12 +142,11 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
||||
type="email"
|
||||
value={editedUser.email}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
error={!!formErrors.email}
|
||||
helperText={formErrors.email}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
@@ -148,7 +156,7 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<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' }}>
|
||||
|
||||
@@ -1,112 +1,104 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
Box,
|
||||
ImageList,
|
||||
ImageListItem,
|
||||
ImageListItemBar,
|
||||
Button,
|
||||
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;
|
||||
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)
|
||||
// Dummy latitude and longitude for demonstration
|
||||
// In a real app, you'd geocode the address to get these.
|
||||
const demoLat = 34.0522;
|
||||
const demoLng = -118.2437; // Example: Los Angeles coordinates
|
||||
console.log(property);
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 3, p: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{property.address}, {property.city}, {property.state} {property.zip_code}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
{property.pictures && property.pictures.length > 0 && (
|
||||
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}>
|
||||
{property.pictures.map((item, index) => (
|
||||
<ImageListItem key={index}>
|
||||
<img
|
||||
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
|
||||
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 item xs={12} md={6}>
|
||||
<Typography variant="body1">
|
||||
<strong>Stats:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Sq Ft: {property.sq_ft || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Bedrooms: {property.num_bedrooms || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Bathrooms: {property.num_bathrooms || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Features: {property.features && property.features.length > 0
|
||||
? property.features.join(', ')
|
||||
: 'None'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Market Value: ${property.market_value || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Amount: ${property.loan_amount || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||
</Typography>
|
||||
{property.latitude && property.longitude ? (
|
||||
<MapComponent lat={property.latitude} lng={property.longitude} address={property.address} />
|
||||
) : (
|
||||
<p>Error loading the map</p>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
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;
|
||||
@@ -1,7 +1,8 @@
|
||||
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 } from 'types';
|
||||
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI, OpenHouseAPI } from 'types';
|
||||
import ProfileCard from './ProfileCard';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import PropertyCard from './PropertyCard.';
|
||||
@@ -13,12 +14,18 @@ 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 {
|
||||
@@ -52,6 +59,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
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 {
|
||||
@@ -104,6 +116,35 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
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'>,
|
||||
) => {
|
||||
@@ -134,29 +175,39 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
// In a real app, this would be an API call to update the property
|
||||
console.log('Saving property: IMPLEMENT ME', updatedProperty);
|
||||
const 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) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
try {
|
||||
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete(
|
||||
`/properties/${propertyId}/`,
|
||||
);
|
||||
console.log(data);
|
||||
// remove the proprty from the list
|
||||
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId));
|
||||
// const indexToRemove = properties.findIndex(property => property.id === propertyId);
|
||||
// console.log(indexToRemove)
|
||||
// if (indexToRemove !== -1) {
|
||||
// const updatedProperties = properties.splice(indexToRemove, 1)
|
||||
// console.log(updatedProperties)
|
||||
// setProperties(updatedProperties);
|
||||
// }
|
||||
} catch {
|
||||
console.log('error removing');
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,8 +233,13 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
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 }}>
|
||||
@@ -194,6 +250,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
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">
|
||||
@@ -203,7 +264,7 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{properties.map((property) => (
|
||||
<Grid item xs={12} key={property.id}>
|
||||
<Grid size={{ xs: 12 }} key={property.id}>
|
||||
{/* <PropertyCard property={property} /> */}
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
@@ -222,6 +283,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { UserAPI, VendorAPI } from 'types';
|
||||
import ChangePasswordCard from './ChangePasswordCard';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
@@ -165,6 +166,10 @@ const VendorProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
onSave={handleSaveVendorProfile}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<ChangePasswordCard />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<ServicesCard
|
||||
|
||||
@@ -123,7 +123,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
event: React.SyntheticEvent,
|
||||
value: string,
|
||||
) => {
|
||||
const test: boolean = true;
|
||||
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||
let data: AutocompleteDataResponseAPI[] = [];
|
||||
if (value.length > 2) {
|
||||
if (test) {
|
||||
@@ -196,7 +196,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
@@ -206,7 +206,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
@@ -216,7 +216,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
@@ -227,7 +227,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Business Name"
|
||||
@@ -240,7 +240,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
helperText={formErrors.business_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<FormControl fullWidth disabled={!isEditing}>
|
||||
<InputLabel id="business-type-label">Business Type</InputLabel>
|
||||
<Select
|
||||
@@ -260,7 +260,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Phone Number"
|
||||
@@ -273,7 +273,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
helperText={formErrors.phone_number}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Autocomplete
|
||||
options={autocompleteOptions}
|
||||
getOptionLabel={(option) => option.description}
|
||||
@@ -307,7 +307,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
@@ -320,7 +320,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
@@ -333,7 +333,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
@@ -346,7 +346,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
helperText={formErrors.zip_code}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Business Description"
|
||||
@@ -358,7 +358,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
@@ -368,7 +368,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Certifications (comma-separated)"
|
||||
@@ -386,7 +386,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="subtitle1">
|
||||
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||
</Typography>
|
||||
|
||||
@@ -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;
|
||||
@@ -159,10 +159,11 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
setIsGernerating(true);
|
||||
|
||||
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
||||
console.log(response);
|
||||
setEditedProperty((prev) => ({
|
||||
...prev,
|
||||
description: response.data.description,
|
||||
}));
|
||||
setIsGernerating(false);
|
||||
|
||||
// TODO: toggle the update
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -195,10 +196,10 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Property Address & Basic Info */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
{isEditing ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={8}>
|
||||
<Grid size={{ xs: 8 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Address"
|
||||
@@ -210,7 +211,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.address}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
@@ -222,7 +223,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.city}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
@@ -234,7 +235,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.state}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Zip Code"
|
||||
@@ -260,7 +261,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
</Grid>
|
||||
|
||||
{/* Pictures */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Pictures:
|
||||
</Typography>
|
||||
@@ -306,8 +307,8 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Description & Stats */}
|
||||
<Grid item xs={12} md={6}>
|
||||
{/* Description */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Description:
|
||||
</Typography>
|
||||
@@ -333,13 +334,16 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
// {property.description || 'No description provided.'}
|
||||
// </Typography>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mt: 2 }} gutterBottom>
|
||||
{/* Stats */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Stats:
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Sq Ft"
|
||||
@@ -351,7 +355,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.sq_ft}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Bedrooms"
|
||||
@@ -363,7 +367,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.num_bedrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Bathrooms"
|
||||
@@ -375,7 +379,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
helperText={formErrors.num_bathrooms}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Features (comma-separated)"
|
||||
@@ -384,7 +388,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
onChange={handleFeaturesChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Market Value"
|
||||
@@ -393,7 +397,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Amount"
|
||||
@@ -402,7 +406,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Term (years)"
|
||||
@@ -412,7 +416,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
onChange={handleNumericChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Loan Start Date"
|
||||
@@ -454,7 +458,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
</Grid>
|
||||
|
||||
{/* Map */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Location on Map:
|
||||
</Typography>
|
||||
|
||||
@@ -7,14 +7,23 @@ 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 }) => {
|
||||
const PropertyListItem: React.FC<PropertyListItemProps> = ({
|
||||
property,
|
||||
onViewDetails,
|
||||
isPublic = false,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetailsClick = () => {
|
||||
// Navigate to the full detail page for this property
|
||||
navigate(`/property/${property.id}/?search=1`);
|
||||
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';
|
||||
|
||||
@@ -1,107 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Grid, Paper, Typography } from '@mui/material';
|
||||
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 | '';
|
||||
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;
|
||||
onSearch: (filters: SearchFilters) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const initialFilters: SearchFilters = {
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
minSqFt: '',
|
||||
maxSqFt: '',
|
||||
minBedrooms: '',
|
||||
maxBedrooms: '',
|
||||
minBathrooms: '',
|
||||
maxBathrooms: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
minSqFt: '',
|
||||
maxSqFt: '',
|
||||
minBedrooms: '',
|
||||
maxBedrooms: '',
|
||||
minBathrooms: '',
|
||||
maxBathrooms: '',
|
||||
};
|
||||
|
||||
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
|
||||
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
|
||||
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 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 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 handleSearchClick = () => {
|
||||
onSearch(filters);
|
||||
};
|
||||
|
||||
const handleClearClick = () => {
|
||||
setFilters(initialFilters);
|
||||
onClear();
|
||||
};
|
||||
const handleClearClick = () => {
|
||||
setFilters(initialFilters);
|
||||
onClear();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Search Properties</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="Address Keyword" name="address" value={filters.address} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="City" name="city" value={filters.city} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="State" name="state" value={filters.state} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField fullWidth label="Zip Code" name="zipCode" value={filters.zipCode} onChange={handleChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Sq Ft" name="minSqFt" type="number" value={filters.minSqFt} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Sq Ft" name="maxSqFt" type="number" value={filters.maxSqFt} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Bedrooms" name="minBedrooms" type="number" value={filters.minBedrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Bedrooms" name="maxBedrooms" type="number" value={filters.maxBedrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Min Bathrooms" name="minBathrooms" type="number" value={filters.minBathrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Max Bathrooms" name="maxBathrooms" type="number" value={filters.maxBathrooms} onChange={handleNumericChange} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
|
||||
Search
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
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;
|
||||
@@ -13,13 +13,22 @@ import {
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import { PropertiesAPI } from 'types';
|
||||
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?: () => void;
|
||||
onStatusChange?: (string) => void;
|
||||
onSavedPropertySave?: () => void;
|
||||
savedProperty: SavedPropertiesAPI | null;
|
||||
sellerDisclosureExists: boolean;
|
||||
}
|
||||
|
||||
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
@@ -27,7 +36,17 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
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':
|
||||
@@ -44,7 +63,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
};
|
||||
|
||||
const timeSinceListed = (dateString: string) => {
|
||||
const listedDate = new Date(dateString);
|
||||
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));
|
||||
@@ -82,7 +101,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
{isOwner ? (
|
||||
<Select
|
||||
value={property.property_status}
|
||||
onChange={onStatusChange}
|
||||
onChange={handleStatusChange}
|
||||
displayEmpty
|
||||
variant="standard"
|
||||
sx={{ mt: 2 }}
|
||||
@@ -104,21 +123,26 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||
</Box>
|
||||
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
|
||||
<Box display="flex" alignItems="center">
|
||||
<VisibilityIcon color="action" sx={{ mr: 1 }} />
|
||||
<VisibilityIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="body1">{property.views} Views</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center">
|
||||
{isOwner ? (
|
||||
<FavoriteIcon color="action" sx={{ mr: 1 }} />
|
||||
<FavoriteIcon color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<FavoriteIcon color="action" sx={{ mr: 1 }} onClick={onSavedPropertySave} />
|
||||
<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="action" sx={{ mr: 1 }} />
|
||||
<AccessTimeIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="body1">{timeOnMarketString}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -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,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;
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -12,12 +11,7 @@ interface VendorCategoryCardProps {
|
||||
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}
|
||||
/>
|
||||
<CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{category.name}
|
||||
@@ -30,13 +24,19 @@ const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSel
|
||||
{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>
|
||||
<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)}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onSelectCategory(category.id)}
|
||||
disabled={category.numVendors == 0}
|
||||
>
|
||||
View Vendors
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -21,19 +21,23 @@ interface VendorDetailProps {
|
||||
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
|
||||
const navigate = useNavigate();
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
const createMessage = () => {
|
||||
const createMessage = async () => {
|
||||
// First see if there is one already
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.get(
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
|
||||
`/conversations/?vendor=${vendor.id}`,
|
||||
);
|
||||
if (data === undefined) {
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.post(`/conversations/`, {
|
||||
property_owner: account?.id,
|
||||
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}`);
|
||||
}
|
||||
|
||||
navigate('/messages');
|
||||
};
|
||||
if (!vendor) {
|
||||
return (
|
||||
@@ -69,7 +73,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
|
||||
return (
|
||||
<Paper elevation={3} sx={{ p: 2 }}>
|
||||
<Grid container sx={{ minHeight: '100%' }}>
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Avatar
|
||||
src={vendor.vendorImageUrl}
|
||||
@@ -132,7 +136,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
|
||||
</Button>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
@@ -141,8 +141,11 @@ function WebSocketProvider({ children }: WebSocketProviderProps) {
|
||||
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(
|
||||
`ws://127.0.0.1:8010/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
||||
`${wsUrl.origin}/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
||||
);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
|
||||
@@ -23,6 +23,13 @@ const attorneyNavItems: NavItem[] = [
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property-search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: '/conversations',
|
||||
@@ -37,6 +44,13 @@ const attorneyNavItems: NavItem[] = [
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
path: '/support',
|
||||
icon: 'ph:question',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default attorneyNavItems;
|
||||
|
||||
@@ -25,21 +25,21 @@ const basicNavItems: NavItem[] = [
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property/search',
|
||||
path: '/property-search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Education',
|
||||
path: '/education',
|
||||
path: '/upgrade',
|
||||
icon: 'ph:student',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Vendors',
|
||||
path: '/vendors',
|
||||
path: '/upgrade',
|
||||
icon: 'ph:storefront',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
@@ -48,27 +48,19 @@ const basicNavItems: NavItem[] = [
|
||||
title: 'Messages',
|
||||
path: '',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
active: false,
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Offers',
|
||||
path: 'offers',
|
||||
icon: 'ph:certificate',
|
||||
active: true,
|
||||
title: 'Documents',
|
||||
path: 'documents',
|
||||
icon: 'ph:folder',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: 'conversations',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
path: 'upgrade',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
@@ -84,30 +76,37 @@ const basicNavItems: NavItem[] = [
|
||||
sublist: [
|
||||
{
|
||||
title: 'Mortgage Calculator',
|
||||
path: '/mortgage-calculator',
|
||||
path: 'mortgage-calculator',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Amortization Table',
|
||||
path: '/amoritization-table',
|
||||
path: 'amoritization-table',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Home Affordability',
|
||||
path: '/home-affordability',
|
||||
path: 'home-affordability',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Net Terms Sheet',
|
||||
path: '/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;
|
||||
|
||||
@@ -3,14 +3,14 @@ import { NavItem } from 'types';
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
path: '/dashboard',
|
||||
icon: 'ion:home-sharp',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/',
|
||||
path: '/dasboard',
|
||||
active: false,
|
||||
collapsible: false,
|
||||
},
|
||||
@@ -25,7 +25,7 @@ const navItems: NavItem[] = [
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property/search',
|
||||
path: '/property-search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
@@ -52,9 +52,9 @@ const navItems: NavItem[] = [
|
||||
collapsible: true,
|
||||
sublist: [
|
||||
{
|
||||
title: 'Offers',
|
||||
path: 'offers',
|
||||
icon: 'ph:certificate',
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
@@ -67,8 +67,8 @@ const navItems: NavItem[] = [
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Bids',
|
||||
path: 'bids',
|
||||
title: 'Documents',
|
||||
path: 'documents',
|
||||
icon: 'ph:chat-circle-dots',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
@@ -108,6 +108,13 @@ const navItems: NavItem[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
@@ -23,6 +23,13 @@ const vendorNavItems: NavItem[] = [
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
path: '/property-search',
|
||||
icon: 'ph:magnifying-glass',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
path: '/conversations',
|
||||
@@ -37,6 +44,13 @@ const vendorNavItems: NavItem[] = [
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
path: '/support',
|
||||
icon: 'ph:question',
|
||||
active: true,
|
||||
collapsible: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default vendorNavItems;
|
||||
|
||||
@@ -29,15 +29,19 @@ const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
||||
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: pathname === navItem.path ? 'primary.main' : '',
|
||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
||||
backgroundColor: { backgroundColor },
|
||||
color: { color },
|
||||
'&:hover': {
|
||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
||||
backgroundColor: { hoverBackgroundColor },
|
||||
opacity: 1.5,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -24,6 +24,7 @@ import vendorNavItems from 'data/vendor-nav-items.js';
|
||||
import basicNavItems from 'data/basic-nav-items.js';
|
||||
import { NavItem } from 'types.js';
|
||||
import attorneyNavItems from 'data/attorney-nav-items.js';
|
||||
import publicNavItems from 'data/public-nav-items.js';
|
||||
|
||||
const Sidebar = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
@@ -43,6 +44,8 @@ const Sidebar = (): ReactElement => {
|
||||
} else if (account.user_type === 'attorney') {
|
||||
nav_items = attorneyNavItems;
|
||||
}
|
||||
} else {
|
||||
nav_items = publicNavItems;
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
||||
26
ditch-the-agent/src/layouts/public-layout/Footer.tsx
Normal file
26
ditch-the-agent/src/layouts/public-layout/Footer.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
39
ditch-the-agent/src/layouts/public-layout/Topbar/index.tsx
Normal file
39
ditch-the-agent/src/layouts/public-layout/Topbar/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AppBar, Button, Stack, Toolbar, Link } from '@mui/material';
|
||||
import { ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Image from 'components/base/Image';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
|
||||
const Topbar = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignIn = () => {
|
||||
navigate('/authentication/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
p: 3.75,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Link href="/">
|
||||
<Image src={logo} width={40} height={40} />
|
||||
</Link>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Button variant="contained" onClick={handleSignIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topbar;
|
||||
40
ditch-the-agent/src/layouts/public-layout/index.tsx
Normal file
40
ditch-the-agent/src/layouts/public-layout/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Stack, Toolbar, Typography, Link } from '@mui/material';
|
||||
import Topbar from './Topbar';
|
||||
import Footer from './Footer';
|
||||
|
||||
const PublicLayout = ({ children }: PropsWithChildren): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<Stack minHeight="100vh" bgcolor="background.default">
|
||||
<Topbar />
|
||||
<Toolbar
|
||||
sx={{
|
||||
pt: 12,
|
||||
width: 1,
|
||||
pb: 0,
|
||||
alignItems: 'start',
|
||||
flex: '1 0 auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
<Stack direction="row" justifyContent={{ xs: 'center' }} 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>
|
||||
</Stack>
|
||||
{/*<Footer />*/}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicLayout;
|
||||
@@ -56,7 +56,7 @@ const BidsPage: React.FC = () => {
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||
{bids.map((bid) => (
|
||||
<Grid item xs={12} key={bid.id}>
|
||||
<Grid size={{ xs: 12 }} key={bid.id}>
|
||||
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
0
ditch-the-agent/src/pages/Documents/Documents.tsx
Normal file
0
ditch-the-agent/src/pages/Documents/Documents.tsx
Normal file
@@ -1,31 +1,85 @@
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import {axiosInstance} from '../../axiosApi'
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
|
||||
import { GenericCategory, GenericItem, VideoAPI, VideoCategory, VideoItem, VideoProgressAPI } from 'types';
|
||||
import {
|
||||
GenericCategory,
|
||||
GenericItem,
|
||||
VideoAPI,
|
||||
VideoCategory,
|
||||
VideoItem,
|
||||
VideoProgressAPI,
|
||||
} from 'types';
|
||||
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
|
||||
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
|
||||
|
||||
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
const Education = (): ReactElement => {
|
||||
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
|
||||
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
|
||||
|
||||
// Simulate fetching data from backend
|
||||
let fetchedVideos: VideoItem[] = []
|
||||
const updateVideoCategories = (videos: VideoItem[]) => {
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ name: string; total: number; completed: number; description: string; imageUrl: string }
|
||||
>();
|
||||
// Populate category details (you might hardcode descriptions/images or fetch them)
|
||||
// For demonstration, let's assume a default image and generate a description
|
||||
const defaultCategoryImages: { [key: string]: string } = {
|
||||
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
|
||||
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
|
||||
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
|
||||
// Add more as needed
|
||||
};
|
||||
videos.forEach((video) => {
|
||||
const categoryId = video.categoryId;
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
let categoryName = video.category;
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
description: `Explore ${video.category} concepts and build your skills.`,
|
||||
imageUrl:
|
||||
defaultCategoryImages[video.category] ||
|
||||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
});
|
||||
}
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.total += 1;
|
||||
if (video.status === 'completed') {
|
||||
categoryData.completed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(
|
||||
([id, data]) => ({
|
||||
id, // id is the category name here
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
imageUrl: data.imageUrl,
|
||||
totalVideos: data.total,
|
||||
completedVideos: data.completed,
|
||||
categoryProgress: data.total > 0 ? (data.completed / data.total) * 100 : 0,
|
||||
}),
|
||||
);
|
||||
setVideoCategories(processedCategories);
|
||||
};
|
||||
|
||||
// Simulate fetching data from backend
|
||||
let fetchedVideos: VideoItem[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
// In a real app, you'd make an API call here
|
||||
const fetchVideos = async () => {
|
||||
// Replace with your actual API call
|
||||
try{
|
||||
|
||||
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/')
|
||||
if(data.length > 0){
|
||||
fetchedVideos = data.map(item => {
|
||||
console.log(item)
|
||||
try {
|
||||
const { data }: AxiosResponse<VideoProgressAPI[]> =
|
||||
await axiosInstance.get('/videos/progress/');
|
||||
if (data.length > 0) {
|
||||
fetchedVideos = data.map((item) => {
|
||||
return {
|
||||
id: String(item.video.id),
|
||||
progress_id: item.id,
|
||||
@@ -37,75 +91,45 @@ const Education = (): ReactElement => {
|
||||
progress: item.progress,
|
||||
videoUrl: item.video.link,
|
||||
duration: item.video.duration,
|
||||
}
|
||||
})
|
||||
setAllVideos(fetchedVideos)
|
||||
}
|
||||
|
||||
}catch (error){
|
||||
console.log('there was an error', error)
|
||||
}
|
||||
const categoryMap = new Map<string, {name: string; total: number; completed: number; description: string; imageUrl: string; }>();
|
||||
|
||||
// Populate category details (you might hardcode descriptions/images or fetch them)
|
||||
// For demonstration, let's assume a default image and generate a description
|
||||
const defaultCategoryImages: { [key: string]: string } = {
|
||||
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
|
||||
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
|
||||
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
fetchedVideos.forEach(video => {
|
||||
const categoryId = video.categoryId;
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
let categoryName = video.category;
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
description: `Explore ${video.category} concepts and build your skills.`,
|
||||
imageUrl: defaultCategoryImages[video.category] || 'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
};
|
||||
});
|
||||
setAllVideos(fetchedVideos);
|
||||
}
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.total += 1;
|
||||
if (video.status === 'completed') {
|
||||
categoryData.completed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(([id, data]) => ({
|
||||
id, // id is the category name here
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
imageUrl: data.imageUrl,
|
||||
totalVideos: data.total,
|
||||
completedVideos: data.completed,
|
||||
categoryProgress: (data.total > 0) ? (data.completed / data.total) * 100 : 0,
|
||||
}));
|
||||
setVideoCategories(processedCategories);
|
||||
} catch (error) {
|
||||
console.log('there was an error', error);
|
||||
}
|
||||
|
||||
updateVideoCategories(fetchedVideos);
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const updateVideoItem = async (
|
||||
time: number,
|
||||
completed: boolean,
|
||||
progress: number,
|
||||
videoId: number,
|
||||
) => {
|
||||
const newPossibleStatus: string = completed ? 'complete' : 'in-progress';
|
||||
// we already saved the data to the backend, so just update it on the Frontend
|
||||
const updatedVideoItems: VideoItem[] = allVideos.map((item) => {
|
||||
if (item.id === videoId) {
|
||||
return {
|
||||
...item,
|
||||
progress: progress,
|
||||
// if the video is already completed, we don't want to reset it
|
||||
status: item.status === 'completed' ? item.status : newPossibleStatus,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
updateVideoCategories(updatedVideoItems);
|
||||
setAllVideos(updatedVideoItems);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// const handleSelectCategory = (categoryName: string) => {
|
||||
// setSelectedCategory(categoryName);
|
||||
// };
|
||||
|
||||
// const handleBackToCategories = () => {
|
||||
// setSelectedCategory(null);
|
||||
// };
|
||||
|
||||
return(
|
||||
<DashboardTemplate<VideoCategory, VideoItem>
|
||||
return (
|
||||
<DashboardTemplate<VideoCategory, VideoItem>
|
||||
pageTitle="Educational Videos"
|
||||
data={{ categories: videoCategories, items: allVideos }}
|
||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||
@@ -123,39 +147,22 @@ const Education = (): ReactElement => {
|
||||
items={itemsInSelectedCategory}
|
||||
onBack={onBack}
|
||||
renderListItem={(item, isSelected, onSelect) => (
|
||||
<VideoListItem video={item as VideoItem} isSelected={isSelected}
|
||||
onSelect={() => {
|
||||
console.log('selecting')
|
||||
onSelect(item.id)}
|
||||
} />
|
||||
<VideoListItem
|
||||
video={item as VideoItem}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => {
|
||||
console.log('selecting');
|
||||
onSelect(item.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderItemDetail={(item) => (
|
||||
<VideoPlayer video={item as VideoItem} />
|
||||
<VideoPlayer video={item as VideoItem} updateVideoItem={updateVideoItem} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
)
|
||||
// (return(
|
||||
// <Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
// <Typography variant="h4" component="h1" gutterBottom>
|
||||
// Educational Videos
|
||||
// </Typography>
|
||||
|
||||
// {selectedCategory ? (
|
||||
// <VideoPlayerPage
|
||||
// categoryName={selectedCategory}
|
||||
// videos={videos.filter(video => video.category === selectedCategory)}
|
||||
// onBack={handleBackToCategories}
|
||||
// // You'll need to pass functions for updating video progress back to Dashboard
|
||||
// // For simplicity, we'll assume updates happen on the backend or in a global state
|
||||
// />
|
||||
// ) : (
|
||||
// <CategoryGrid categories={categories} onSelectCategory={handleSelectCategory} />
|
||||
// )}
|
||||
// </Container>
|
||||
// )
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Education;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
|
||||
import {
|
||||
@@ -19,6 +20,11 @@ import { axiosInstance } from '../../axiosApi';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Autocomplete,
|
||||
List,
|
||||
Grid,
|
||||
ListItem,
|
||||
@@ -36,6 +42,7 @@ import { AxiosResponse } from 'axios';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
|
||||
import { formatTimestamp } from 'utils';
|
||||
import CreateConversationDialogContent from 'components/sections/dashboard/Home/Messages/CreateConversationDialogContent';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -53,14 +60,38 @@ interface Conversation {
|
||||
}
|
||||
|
||||
const Messages = (): ReactElement => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
||||
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
||||
const { account } = useContext(AccountContext);
|
||||
const [openCreateConversationDialog, setOpenCreateConversationDialog] = useState(false);
|
||||
const [vendors, setVendors] = useState<VendorItem[]>([]);
|
||||
const [selectedVendor, setSelectedVendor] = useState<VendorItem | null>(null);
|
||||
|
||||
// Auto-scroll to the bottom of the messages when they update
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedConversation = searchParams.get('selectedConversation');
|
||||
if (selectedConversation) {
|
||||
console.log(selectedConversation);
|
||||
setSelectedConversationId(parseInt(selectedConversation, 10));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<VendorItem[]> = await axiosInstance.get('/vendors/');
|
||||
setVendors(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch vendors', error);
|
||||
}
|
||||
};
|
||||
fetchVendors();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
@@ -103,6 +134,72 @@ const Messages = (): ReactElement => {
|
||||
}
|
||||
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
|
||||
|
||||
const handleOpenCreateConversationDialog = () => {
|
||||
setOpenCreateConversationDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseCreateConversationDialog = () => {
|
||||
setOpenCreateConversationDialog(false);
|
||||
setSelectedVendor(null);
|
||||
};
|
||||
|
||||
const handleCreateConversation = async () => {
|
||||
if (!selectedVendor) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(selectedVendor);
|
||||
|
||||
// first make sure that there isn't already a conversation
|
||||
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
|
||||
`/conversations/?vendor=${selectedVendor.user.id}`,
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
const { data }: AxiosResponse<ConverationAPI> = await axiosInstance.post(
|
||||
`/conversations/`,
|
||||
{
|
||||
property_owner: account?.id,
|
||||
vendor: selectedVendor.user.id,
|
||||
},
|
||||
);
|
||||
console.log(data);
|
||||
const newConversationData = data;
|
||||
console.log(newConversationData);
|
||||
|
||||
const lastMessageSnippet: string =
|
||||
newConversationData.messages.length > 0
|
||||
? newConversationData.messages[newConversationData.messages.length - 1].text
|
||||
: '';
|
||||
const messages: Message[] = newConversationData.messages.map((message: any) => {
|
||||
return {
|
||||
id: message.id,
|
||||
content: message.text,
|
||||
timestamp: message.timestamp,
|
||||
senderId:
|
||||
message.sender === newConversationData.property_owner.user.id ? 'owner' : 'vendor',
|
||||
};
|
||||
});
|
||||
const newConversation: Conversation = {
|
||||
id: newConversationData.id,
|
||||
withName: selectedVendor.business_name,
|
||||
lastMessageTimestamp: newConversationData.updated_at,
|
||||
lastMessageSnippet: lastMessageSnippet,
|
||||
messages: messages,
|
||||
};
|
||||
|
||||
setConversations((prev) => [newConversation, ...prev]);
|
||||
setSelectedConversationId(newConversation.id);
|
||||
} else {
|
||||
setSelectedConversationId(data[0].id);
|
||||
}
|
||||
|
||||
handleCloseCreateConversationDialog();
|
||||
} catch (error) {
|
||||
console.error('Failed to create conversation', error);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
|
||||
|
||||
// Handle sending a new message
|
||||
@@ -147,19 +244,16 @@ const Messages = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||
>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
<Grid container sx={{ height: '100%', width: '100%' }}>
|
||||
{/* Left Panel: Conversation List */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={4}
|
||||
size={{ xs: 12, md: 4 }}
|
||||
sx={{
|
||||
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||
display: 'flex',
|
||||
@@ -172,8 +266,13 @@ const Messages = (): ReactElement => {
|
||||
Conversations
|
||||
</Typography>
|
||||
{account?.user_type === 'property_owner' && (
|
||||
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
|
||||
New Conversation
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
onClick={handleOpenCreateConversationDialog}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -234,7 +333,7 @@ const Messages = (): ReactElement => {
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Conversation Detail */}
|
||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Conversation Header */}
|
||||
@@ -355,6 +454,14 @@ const Messages = (): ReactElement => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
<CreateConversationDialogContent
|
||||
showDialog={openCreateConversationDialog}
|
||||
closeDialog={handleCloseCreateConversationDialog}
|
||||
createConversation={handleCreateConversation}
|
||||
vendors={vendors}
|
||||
setSelectedVendor={setSelectedVendor}
|
||||
selectedVendor={selectedVendor}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
|
||||
import { ConverationAPI, ConversationItem, GenericCategory, MessagesAPI, OfferAPI, VendorCategory, VendorItem } from 'types';
|
||||
import {
|
||||
ConverationAPI,
|
||||
ConversationItem,
|
||||
GenericCategory,
|
||||
MessagesAPI,
|
||||
OfferAPI,
|
||||
VendorCategory,
|
||||
VendorItem,
|
||||
} from 'types';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||
import {axiosInstance} from '../../axiosApi'
|
||||
import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack, Accordion, AccordionActions, AccordionSummary, AccordionDetails, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
List,
|
||||
Grid,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Avatar,
|
||||
Stack,
|
||||
Accordion,
|
||||
AccordionActions,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { AxiosResponse } from 'axios';
|
||||
@@ -37,304 +66,348 @@ interface Offer {
|
||||
lastMessageTimestamp: string;
|
||||
market_value: string;
|
||||
offer_value: string;
|
||||
|
||||
}
|
||||
|
||||
type submitOfferProps = {
|
||||
offer_id: number,
|
||||
sender_id: number,
|
||||
property_id: number,
|
||||
}
|
||||
offer_id: number;
|
||||
sender_id: number;
|
||||
property_id: number;
|
||||
};
|
||||
|
||||
const Offers = (): ReactElement => {
|
||||
const [offers, setOffers] = useState<Offer[]>([]);
|
||||
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
|
||||
const {account} = useContext(AccountContext)
|
||||
|
||||
const { account } = useContext(AccountContext);
|
||||
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const closeDialog = () => {
|
||||
setShowDialog(false);
|
||||
}
|
||||
setShowDialog(false);
|
||||
};
|
||||
const createOffer = async (property_id: number) => {
|
||||
console.log(account)
|
||||
if(account)
|
||||
{
|
||||
console.log(account);
|
||||
if (account) {
|
||||
console.log({
|
||||
user: account.id,
|
||||
property: property_id,
|
||||
|
||||
|
||||
})
|
||||
response = await axiosInstance.post(`/offers/`,
|
||||
{
|
||||
});
|
||||
response = await axiosInstance.post(`/offers/`, {
|
||||
user: account.id,
|
||||
property: property_id,
|
||||
|
||||
|
||||
})
|
||||
});
|
||||
setShowDialog(false);
|
||||
|
||||
|
||||
setShowDialog(false)
|
||||
|
||||
setShowDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
const submitOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
||||
response = await axiosInstance.put(`/offers/${offer_id}/`,
|
||||
{
|
||||
user: sender_id,
|
||||
property: property_id,
|
||||
status:'submitted'
|
||||
|
||||
})
|
||||
console.log(response)
|
||||
const submitOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {
|
||||
response = await axiosInstance.put(`/offers/${offer_id}/`, {
|
||||
user: sender_id,
|
||||
property: property_id,
|
||||
status: 'submitted',
|
||||
});
|
||||
console.log(response);
|
||||
|
||||
// TODO: update the selectedOffer' status
|
||||
const updatedOffers: Offer[] = offers.map(item => ({
|
||||
...item, // Spread operator to copy existing properties
|
||||
status: 'submitted'
|
||||
const updatedOffers: Offer[] = offers.map((item) => ({
|
||||
...item, // Spread operator to copy existing properties
|
||||
status: 'submitted',
|
||||
}));
|
||||
setOffers(updatedOffers);
|
||||
}
|
||||
|
||||
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const withdrawOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchOffers = async () => {
|
||||
try{
|
||||
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/')
|
||||
console.log(data)
|
||||
if (data.length > 0){
|
||||
console.log(data)
|
||||
const fetchedOffers: Offer[] = data.map(item => {
|
||||
|
||||
console.log(item)
|
||||
return {
|
||||
id: item.id,
|
||||
sender: item.user.first_name + " " + item.user.last_name,
|
||||
status: item.status,
|
||||
address: item.property.address,
|
||||
is_active: item.is_active,
|
||||
lastMessageTimestamp: item.updated_at,
|
||||
market_value: item.property.market_value,
|
||||
offer_value: '100000',
|
||||
sender_id: item.user.id,
|
||||
property_id: item.property.id
|
||||
|
||||
}
|
||||
})
|
||||
console.log(fetchedOffers)
|
||||
setOffers(fetchedOffers);
|
||||
|
||||
}
|
||||
|
||||
}catch(error){
|
||||
|
||||
try {
|
||||
const { data }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/');
|
||||
console.log(data);
|
||||
if (data.length > 0) {
|
||||
console.log(data);
|
||||
const fetchedOffers: Offer[] = data.map((item) => {
|
||||
console.log(item);
|
||||
return {
|
||||
id: item.id,
|
||||
sender: item.user.first_name + ' ' + item.user.last_name,
|
||||
status: item.status,
|
||||
address: item.property.address,
|
||||
is_active: item.is_active,
|
||||
lastMessageTimestamp: item.updated_at,
|
||||
market_value: item.property.market_value,
|
||||
offer_value: '100000',
|
||||
sender_id: item.user.id,
|
||||
property_id: item.property.id,
|
||||
};
|
||||
});
|
||||
console.log(fetchedOffers);
|
||||
setOffers(fetchedOffers);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
fetchOffers();
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
type offerChoice = 'accept' | 'counter' | 'reject';
|
||||
type offerChoice = 'accept' | 'counter' | 'reject';
|
||||
|
||||
const handleOffer = async (choice: offerChoice) => {
|
||||
console.log(choice)
|
||||
}
|
||||
const handleOffer = async (choice: offerChoice) => {
|
||||
console.log(choice);
|
||||
};
|
||||
|
||||
const selectedOffer = offers.find(
|
||||
(conv) => conv.id === selectedOfferId
|
||||
);
|
||||
const selectedOffer = offers.find((conv) => conv.id === selectedOfferId);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
{/* Left Panel: Offer List */}
|
||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||
<Stack direction="row" sx={{width:'100%'}}>
|
||||
|
||||
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||
>
|
||||
<Grid container sx={{ height: '100%' }}>
|
||||
{/* Left Panel: Offer List */}
|
||||
<Grid
|
||||
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' }}>
|
||||
Offers
|
||||
</Typography>
|
||||
<Button
|
||||
variant='contained'
|
||||
color='primary'
|
||||
sx={{ml:'auto'}}
|
||||
onClick={() => setShowDialog(true)}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Create Offer
|
||||
Create Offer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
{offers.length === 0 ? (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography>No offers submited yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
offers
|
||||
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
selected={selectedOfferId === conv.id}
|
||||
onClick={() => setSelectedOfferId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{conv.sender}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
|
||||
{conv.address}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimestamp(conv.lastMessageTimestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Offer Detail */}
|
||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedOffer ? (
|
||||
<>
|
||||
{/* Offer Header */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar sx={{ bgcolor: 'purple.200'}}>
|
||||
{selectedOffer.sender.split(' ').map(n => n[0]).join('')}
|
||||
</Avatar>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{selectedOffer.sender}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
{/* add the offer details here */}
|
||||
<Accordion>
|
||||
<AccordionSummary>
|
||||
Offer for {selectedOffer.address}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>
|
||||
Offer Price: <strong>{selectedOffer.offer_value}</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Waived Inspection: <strong>No</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Closing date: <strong>90 days</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Status: <strong>{selectedOffer.status}</strong>
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
<AccordionActions>
|
||||
|
||||
{selectedOffer.status === 'submitted' ? (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => handleOffer('accept')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => handleOffer('counter')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Counter
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={ async() => handleOffer('reject')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
): (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => withdrawOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
||||
endIcon={<DeleteForeverIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Withdraw Offer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
</AccordionActions>
|
||||
</Accordion>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Message Input */}
|
||||
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6">Select an offer to view</Typography>
|
||||
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
{offers.length === 0 ? (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography>No offers submited yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
offers
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastMessageTimestamp).getTime() -
|
||||
new Date(a.lastMessageTimestamp).getTime(),
|
||||
)
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
selected={selectedOfferId === conv.id}
|
||||
onClick={() => setSelectedOfferId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{conv.sender}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1, pr: 1 }}
|
||||
>
|
||||
{conv.address}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimestamp(conv.lastMessageTimestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
</List>
|
||||
</Grid>
|
||||
<CreateOfferDialog showDialog={showDialog} createOffer={createOffer} closeDialog={closeDialog} />
|
||||
|
||||
</Paper>
|
||||
</Container>
|
||||
{/* Right Panel: Offer Detail */}
|
||||
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedOffer ? (
|
||||
<>
|
||||
{/* Offer Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: 'purple.200' }}>
|
||||
{selectedOffer.sender
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</Avatar>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{selectedOffer.sender}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
{/* add the offer details here */}
|
||||
<Accordion>
|
||||
<AccordionSummary>Offer for {selectedOffer.address}</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>
|
||||
Offer Price: <strong>{selectedOffer.offer_value}</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Waived Inspection: <strong>No</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Closing date: <strong>90 days</strong>
|
||||
</Typography>
|
||||
<Typography>
|
||||
Status: <strong>{selectedOffer.status}</strong>
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
<AccordionActions>
|
||||
{selectedOffer.status === 'submitted' ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => handleOffer('accept')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => handleOffer('counter')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Counter
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => handleOffer('reject')}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () =>
|
||||
withdrawOffer({
|
||||
offer_id: selectedOffer.id,
|
||||
sender_id: selectedOffer.sender_id,
|
||||
property_id: selectedOffer.property_id,
|
||||
})
|
||||
}
|
||||
endIcon={<DeleteForeverIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Withdraw Offer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () =>
|
||||
submitOffer({
|
||||
offer_id: selectedOffer.id,
|
||||
sender_id: selectedOffer.sender_id,
|
||||
property_id: selectedOffer.property_id,
|
||||
})
|
||||
}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionActions>
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
{/* Message Input */}
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 3,
|
||||
color: 'grey.500',
|
||||
}}
|
||||
>
|
||||
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6">Select an offer to view</Typography>
|
||||
<Typography variant="body2">
|
||||
Click on an offer from the left panel to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<CreateOfferDialog
|
||||
showDialog={showDialog}
|
||||
createOffer={createOffer}
|
||||
closeDialog={closeDialog}
|
||||
/>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Offers;
|
||||
@@ -4,165 +4,238 @@ import { PropertiesAPI } from 'types';
|
||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||
|
||||
|
||||
// Reusing the mockProperties from PropertyDetailPage for consistent data
|
||||
const mockProperties: PropertiesAPI[] = [
|
||||
{
|
||||
id: 101,
|
||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
||||
address: '123 Main St',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '500000',
|
||||
loan_amount: '300000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2020-05-01',
|
||||
created_at: '2020-04-20',
|
||||
last_updated: '2023-10-10',
|
||||
pictures: [
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Living',
|
||||
],
|
||||
description: 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
|
||||
sq_ft: 1800,
|
||||
features: ['Garage', 'Central AC', 'Hardwood Floors'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 34.0522, // Example coordinates for Los Angeles
|
||||
longitude: -118.2437,
|
||||
{
|
||||
id: 101,
|
||||
owner: {
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'john.doe@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
user_type: 'property_owner',
|
||||
is_active: true,
|
||||
date_joined: '2023-01-15',
|
||||
tos_signed: true,
|
||||
profile_created: true,
|
||||
tier: 'basic',
|
||||
},
|
||||
phone_number: '123-456-7890',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
||||
address: '456 Oak Ave',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '750000',
|
||||
loan_amount: '500000',
|
||||
loan_term: 20,
|
||||
loan_start_date: '2022-01-10',
|
||||
created_at: '2021-12-01',
|
||||
last_updated: '2023-11-20',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
|
||||
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
|
||||
sq_ft: 2500,
|
||||
features: ['Pool', 'Fireplace', 'Large Yard'],
|
||||
num_bedrooms: 4,
|
||||
num_bathrooms: 3,
|
||||
latitude: 34.075, // Another example coordinate
|
||||
longitude: -118.30,
|
||||
address: '123 Main St',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '500000',
|
||||
loan_amount: '300000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2020-05-01',
|
||||
created_at: '2020-04-20',
|
||||
last_updated: '2023-10-10',
|
||||
pictures: [
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
|
||||
'https://via.placeholder.com/600x400?text=Property+1+Living',
|
||||
],
|
||||
description:
|
||||
'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
|
||||
sq_ft: 1800,
|
||||
features: ['Garage', 'Central AC', 'Hardwood Floors'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 34.0522, // Example coordinates for Los Angeles
|
||||
longitude: -118.2437,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
owner: {
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'john.doe@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
user_type: 'property_owner',
|
||||
is_active: true,
|
||||
date_joined: '2023-01-15',
|
||||
tos_signed: true,
|
||||
profile_created: true,
|
||||
tier: 'basic',
|
||||
},
|
||||
phone_number: '123-456-7890',
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
owner: { user: { id: 99, email: 'another.owner@example.com', first_name: 'Jane', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '987-654-3210' },
|
||||
address: '789 Pine Lane',
|
||||
city: 'Otherville',
|
||||
state: 'NY',
|
||||
zip_code: '10001',
|
||||
market_value: '1200000',
|
||||
loan_amount: '800000',
|
||||
loan_term: 15,
|
||||
loan_start_date: '2021-03-20',
|
||||
created_at: '2021-02-15',
|
||||
last_updated: '2024-01-05',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
|
||||
description: 'Luxury apartment in the heart of the city with stunning views.',
|
||||
sq_ft: 1200,
|
||||
features: ['City View', 'Gym Access', 'Doorman'],
|
||||
num_bedrooms: 2,
|
||||
num_bathrooms: 2,
|
||||
latitude: 40.7128, // NYC
|
||||
longitude: -74.0060,
|
||||
address: '456 Oak Ave',
|
||||
city: 'Anytown',
|
||||
state: 'CA',
|
||||
zip_code: '90210',
|
||||
market_value: '750000',
|
||||
loan_amount: '500000',
|
||||
loan_term: 20,
|
||||
loan_start_date: '2022-01-10',
|
||||
created_at: '2021-12-01',
|
||||
last_updated: '2023-11-20',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
|
||||
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
|
||||
sq_ft: 2500,
|
||||
features: ['Pool', 'Fireplace', 'Large Yard'],
|
||||
num_bedrooms: 4,
|
||||
num_bathrooms: 3,
|
||||
latitude: 34.075, // Another example coordinate
|
||||
longitude: -118.3,
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
owner: {
|
||||
user: {
|
||||
id: 99,
|
||||
email: 'another.owner@example.com',
|
||||
first_name: 'Jane',
|
||||
last_name: 'Doe',
|
||||
user_type: 'property_owner',
|
||||
is_active: true,
|
||||
date_joined: '2023-01-15',
|
||||
tos_signed: true,
|
||||
profile_created: true,
|
||||
tier: 'basic',
|
||||
},
|
||||
phone_number: '987-654-3210',
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
owner: { user: { id: 100, email: 'test.user@example.com', first_name: 'Bob', last_name: 'Brown', user_type: 'property_owner', is_active: true, date_joined: '2024-01-01', tos_signed: true, profile_created: true, tier: 'premium' }, phone_number: '555-987-6543' },
|
||||
address: '101 Elm Street',
|
||||
city: 'Sampleton',
|
||||
state: 'TX',
|
||||
zip_code: '75001',
|
||||
market_value: '350000',
|
||||
loan_amount: '250000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2023-07-01',
|
||||
created_at: '2023-06-20',
|
||||
last_updated: '2024-06-15',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
|
||||
description: 'Cozy starter home with a large yard, ideal for families.',
|
||||
sq_ft: 1500,
|
||||
features: ['Large Yard', 'New Roof'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 32.7767, // Dallas
|
||||
longitude: -96.7970,
|
||||
}
|
||||
address: '789 Pine Lane',
|
||||
city: 'Otherville',
|
||||
state: 'NY',
|
||||
zip_code: '10001',
|
||||
market_value: '1200000',
|
||||
loan_amount: '800000',
|
||||
loan_term: 15,
|
||||
loan_start_date: '2021-03-20',
|
||||
created_at: '2021-02-15',
|
||||
last_updated: '2024-01-05',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
|
||||
description: 'Luxury apartment in the heart of the city with stunning views.',
|
||||
sq_ft: 1200,
|
||||
features: ['City View', 'Gym Access', 'Doorman'],
|
||||
num_bedrooms: 2,
|
||||
num_bathrooms: 2,
|
||||
latitude: 40.7128, // NYC
|
||||
longitude: -74.006,
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
owner: {
|
||||
user: {
|
||||
id: 100,
|
||||
email: 'test.user@example.com',
|
||||
first_name: 'Bob',
|
||||
last_name: 'Brown',
|
||||
user_type: 'property_owner',
|
||||
is_active: true,
|
||||
date_joined: '2024-01-01',
|
||||
tos_signed: true,
|
||||
profile_created: true,
|
||||
tier: 'premium',
|
||||
},
|
||||
phone_number: '555-987-6543',
|
||||
},
|
||||
address: '101 Elm Street',
|
||||
city: 'Sampleton',
|
||||
state: 'TX',
|
||||
zip_code: '75001',
|
||||
market_value: '350000',
|
||||
loan_amount: '250000',
|
||||
loan_term: 30,
|
||||
loan_start_date: '2023-07-01',
|
||||
created_at: '2023-06-20',
|
||||
last_updated: '2024-06-15',
|
||||
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
|
||||
description: 'Cozy starter home with a large yard, ideal for families.',
|
||||
sq_ft: 1500,
|
||||
features: ['Large Yard', 'New Roof'],
|
||||
num_bedrooms: 3,
|
||||
num_bathrooms: 2,
|
||||
latitude: 32.7767, // Dallas
|
||||
longitude: -96.797,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const Property: React.FC = () => {
|
||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
const filterProperties = (filters: any) => {
|
||||
const filtered = mockProperties.filter(property => {
|
||||
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
|
||||
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
|
||||
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
|
||||
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
||||
const filterProperties = (filters: any) => {
|
||||
const filtered = mockProperties.filter((property) => {
|
||||
const addressMatch = filters.address
|
||||
? property.address.toLowerCase().includes(filters.address.toLowerCase())
|
||||
: true;
|
||||
const cityMatch = filters.city
|
||||
? property.city.toLowerCase().includes(filters.city.toLowerCase())
|
||||
: true;
|
||||
const stateMatch = filters.state
|
||||
? property.state.toLowerCase() === filters.state.toLowerCase()
|
||||
: true;
|
||||
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
||||
|
||||
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
||||
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
||||
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
||||
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
||||
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
||||
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
||||
const sqFtMatch =
|
||||
(filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
||||
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
||||
const bedroomsMatch =
|
||||
(filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
||||
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
||||
const bathroomsMatch =
|
||||
(filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
||||
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
||||
|
||||
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
|
||||
sqFtMatch && bedroomsMatch && bathroomsMatch;
|
||||
});
|
||||
setSearchResults(filtered);
|
||||
setInitialLoad(false);
|
||||
};
|
||||
return (
|
||||
addressMatch &&
|
||||
cityMatch &&
|
||||
stateMatch &&
|
||||
zipCodeMatch &&
|
||||
sqFtMatch &&
|
||||
bedroomsMatch &&
|
||||
bathroomsMatch
|
||||
);
|
||||
});
|
||||
setSearchResults(filtered);
|
||||
setInitialLoad(false);
|
||||
};
|
||||
|
||||
const handleSearch = (filters: any) => {
|
||||
filterProperties(filters);
|
||||
};
|
||||
const handleSearch = (filters: any) => {
|
||||
filterProperties(filters);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchResults([]); // Clear results on clear
|
||||
setInitialLoad(true); // Reset to initial state
|
||||
};
|
||||
const handleClearSearch = () => {
|
||||
setSearchResults([]); // Clear results on clear
|
||||
setInitialLoad(true); // Reset to initial state
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Property Search
|
||||
</Typography>
|
||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Property Search
|
||||
</Typography>
|
||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||
|
||||
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
||||
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{searchResults.length === 0 && !initialLoad ? (
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||
</Grid>
|
||||
) : (
|
||||
searchResults.map(property => (
|
||||
<Grid item xs={12} sm={6} md={4} key={property.id}>
|
||||
<PropertyListItem
|
||||
property={property}
|
||||
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
||||
{initialLoad
|
||||
? 'Enter search criteria to find properties.'
|
||||
: `Search Results (${searchResults.length} found)`}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{searchResults.length === 0 && !initialLoad ? (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||
</Grid>
|
||||
) : (
|
||||
searchResults.map((property) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={property.id}>
|
||||
<PropertyListItem
|
||||
property={property}
|
||||
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Property;
|
||||
@@ -185,7 +258,6 @@ export default Property;
|
||||
// import HouseIcon from '@mui/icons-material/House';
|
||||
// import { formatTimestamp } from 'utils';
|
||||
|
||||
|
||||
// const Property = (): ReactElement => {
|
||||
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
|
||||
@@ -203,7 +275,6 @@ export default Property;
|
||||
|
||||
// }
|
||||
|
||||
|
||||
// }catch(error){
|
||||
// console.log(error)
|
||||
// }
|
||||
@@ -224,7 +295,6 @@ export default Property;
|
||||
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||
// <Stack direction="row" sx={{width:'100%'}}>
|
||||
|
||||
|
||||
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
// Properties
|
||||
// </Typography>
|
||||
@@ -333,7 +403,6 @@ export default Property;
|
||||
|
||||
// </Box>
|
||||
|
||||
|
||||
// </>
|
||||
// ) : (
|
||||
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material';
|
||||
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Alert,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
private_excludeVariablesFromRoot,
|
||||
} from '@mui/material';
|
||||
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
||||
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
||||
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
||||
@@ -9,16 +20,18 @@ import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseC
|
||||
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
||||
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
||||
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
|
||||
|
||||
const PropertyDetailPage: React.FC = () => {
|
||||
// In a real app, you'd get propertyId from URL params or a global state
|
||||
const { account, accountLoading } = useContext(AccountContext);
|
||||
const { propertyId } = useParams<{ propertyId: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isSearch = searchParams.get('search') === '1';
|
||||
|
||||
@@ -26,175 +39,282 @@ const PropertyDetailPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [savedProperty, setSavedProperty] = useState<SavedPropertiesAPI | null>(null);
|
||||
|
||||
if (accountLoading) {
|
||||
return <>Page is loading</>;
|
||||
} else if (!accountLoading && !account) {
|
||||
return <>There was an error</>;
|
||||
} else {
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const getProperty = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = isSearch
|
||||
? `/properties/${propertyId}/?search=1`
|
||||
: `/properties/${propertyId}/`;
|
||||
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||
if (isSearch) {
|
||||
// kick the view count
|
||||
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
|
||||
}
|
||||
if (data !== undefined) {
|
||||
setProperty(data);
|
||||
}
|
||||
} catch {
|
||||
setError('Property not found.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
getProperty();
|
||||
}, [propertyId]);
|
||||
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
// In a real app, this would be an API call to update the property
|
||||
console.log('Saving property:', updatedProperty);
|
||||
setProperty(updatedProperty);
|
||||
setMessage({ type: 'success', text: 'Property details updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const onStatusChange = () => {
|
||||
if (property) {
|
||||
setProperty((property) => ({ ...property, status: 'active' }));
|
||||
}
|
||||
};
|
||||
|
||||
const onSavedPropertySave = async () => {
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const getProperty = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.post(`/saved-properties/`, {
|
||||
property: property.id,
|
||||
user: account.id,
|
||||
});
|
||||
console.log(response);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = isSearch ? `/properties/${propertyId}/?search=1` : `/properties/${propertyId}/`;
|
||||
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||
if (isSearch) {
|
||||
// kick the view count
|
||||
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
|
||||
}
|
||||
if (data !== undefined) {
|
||||
setProperty(data);
|
||||
}
|
||||
} catch {
|
||||
setError('Property not found.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME');
|
||||
const getSavedProperties = async () => {
|
||||
if (account) {
|
||||
// only fetch if logged in
|
||||
try {
|
||||
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
|
||||
await axiosInstance.get('/saved-properties/');
|
||||
setSavedProperty(data.find((item) => item.property.toString() === propertyId));
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
getProperty();
|
||||
getSavedProperties();
|
||||
}, [propertyId, account, isSearch]);
|
||||
|
||||
const handleOfferSubmit = (offerAmount: number) => {
|
||||
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`);
|
||||
// Here you would send the offer to your backend API
|
||||
};
|
||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||
// In a real app, this would be an API call to update the property
|
||||
console.log('Saving property:', updatedProperty);
|
||||
setProperty(updatedProperty);
|
||||
setMessage({ type: 'success', text: 'Property details updated successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading property details...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="info">No property data available.</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if the current user is the owner of this property
|
||||
const isOwnerOfProperty = account.id === property.owner.user.id;
|
||||
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||
let listed_price: string;
|
||||
if (property.listed_price === undefined || property.listed_price === null) {
|
||||
listed_price = 'No Price';
|
||||
const onStatusChange = async (value: string) => {
|
||||
if (property) {
|
||||
try {
|
||||
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, {
|
||||
property_status: value,
|
||||
});
|
||||
setProperty((prev) => (prev ? { ...prev, property_status: value } : null));
|
||||
setMessage({ type: 'success', text: `Your listing is now ${value}` });
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'There was an error saving your selection. Please try again',
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} else {
|
||||
listed_price = parseFloat(property.listed_price).toString();
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'There was an error saving your selection. Please refresh the page and try again',
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(property);
|
||||
const onSavedPropertySave = async () => {
|
||||
if (property && account) {
|
||||
try {
|
||||
if (savedProperty) {
|
||||
await axiosInstance.delete<SavedPropertiesAPI>(`/saved-properties/${savedProperty.id}/`);
|
||||
setSavedProperty(null);
|
||||
setProperty((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
saves: prev.saves - 1,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
const { data } = await axiosInstance.post<SavedPropertiesAPI>(`/saved-properties/`, {
|
||||
property: property.id,
|
||||
user: account.id,
|
||||
});
|
||||
setSavedProperty(data);
|
||||
setProperty((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
saves: prev.saves + 1,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'There was an error saving your selection. Please try again.',
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = (propertyId: number) => {
|
||||
console.log('handle delete. IMPLEMENT ME', propertyId);
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (property && property.owner && property.owner.user) {
|
||||
navigate(`/messages?recipient=${property.owner.user.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfferSubmit = async (
|
||||
offerAmount: number,
|
||||
closing_days: number,
|
||||
contingencies: string,
|
||||
): Promise<{ status: number; message?: string }> => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/documents/upload/', {
|
||||
document_type: 'offer',
|
||||
property: propertyId,
|
||||
offer_price: offerAmount,
|
||||
closing_days: closing_days,
|
||||
contingencies: contingencies,
|
||||
});
|
||||
return { status: response.status };
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
status: error.response.status,
|
||||
message: error.response.data?.detail || 'Failed to submit offer.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 500,
|
||||
message: 'An unexpected error occurred.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || accountLoading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Property Details */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
isPublicPage={true}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={isOwnerOfProperty}
|
||||
onDelete={handleDeleteProperty}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Status, Cost, Offers */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<PropertyStatusCard
|
||||
property={property}
|
||||
isOwner={isOwnerOfProperty}
|
||||
onStatusChange={onStatusChange}
|
||||
onSavedPropertySave={onSavedPropertySave}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<OfferSubmissionCard
|
||||
onOfferSubmit={handleOfferSubmit}
|
||||
listingStatus={property.property_status}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<OpenHouseCard openHouses={property.open_houses} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Information */}
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<WalkScoreCard walkScore={property.walk_score} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{property.schools && <SchoolCard schools={property.schools} />}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading property details...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="info">No property data available.</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if the current user is the owner of this property
|
||||
const isOwnerOfProperty = account ? account.id === property.owner.user.id : false;
|
||||
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||
|
||||
const sellerDisclosureExists = property.documents
|
||||
? property.documents.some(
|
||||
(doc) => doc.document_type === 'seller_disclosure' && doc.sub_document,
|
||||
)
|
||||
: false;
|
||||
|
||||
const disclosureDocument = property.documents?.find(
|
||||
(doc) => doc.document_type === 'seller_disclosure',
|
||||
);
|
||||
const sellerDisclosureData = disclosureDocument?.sub_document as any; // Using 'any' to avoid type conflicts for now.
|
||||
|
||||
const existingOffer = property.documents
|
||||
? property.documents.find((doc) => doc.document_type === 'offer_letter')
|
||||
: undefined;
|
||||
|
||||
// TODO fix this
|
||||
console.log(property);
|
||||
console.log(property.documents);
|
||||
console.log(existingOffer);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Property Details */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
isPublicPage={true}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={isOwnerOfProperty}
|
||||
onDelete={() => handleDeleteProperty(property.id)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<PropertyStatusCard
|
||||
property={property}
|
||||
isOwner={isOwnerOfProperty}
|
||||
onStatusChange={onStatusChange}
|
||||
onSavedPropertySave={onSavedPropertySave}
|
||||
savedProperty={savedProperty}
|
||||
sellerDisclosureExists={sellerDisclosureExists}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||
</Grid>
|
||||
{!isOwnerOfProperty && (
|
||||
<>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<OfferSubmissionCard
|
||||
onOfferSubmit={handleOfferSubmit}
|
||||
listingStatus={property.property_status}
|
||||
listingPrice={priceForAnalysis}
|
||||
existingOffer={existingOffer ? { document_id: existingOffer.id } : undefined}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<SellerInformationCard
|
||||
sellerDisclosureExists={sellerDisclosureExists}
|
||||
onSendMessage={handleSendMessage}
|
||||
disclosureData={sellerDisclosureData}
|
||||
property={property}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<OpenHouseCard openHouses={property.open_houses} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Information */}
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<WalkScoreCard walkScore={property.walk_score} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{property.schools && <SchoolCard schools={property.schools} />}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyDetailPage;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Container, Typography, Box, Grid, Alert } from '@mui/material';
|
||||
import { PropertiesAPI } from 'types';
|
||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import MapSerachComponent from 'components/base/MapSearchComponent';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
@@ -125,7 +125,7 @@ const PropertySearchPage: React.FC = () => {
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Property List Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
||||
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
||||
@@ -149,7 +149,7 @@ const PropertySearchPage: React.FC = () => {
|
||||
</Grid>
|
||||
|
||||
{/* Map Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MapSerachComponent
|
||||
center={mapState.center}
|
||||
zoom={mapState.zoom}
|
||||
|
||||
162
ditch-the-agent/src/pages/Property/PublicPropertyDetail.tsx
Normal file
162
ditch-the-agent/src/pages/Property/PublicPropertyDetail.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Alert,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
||||
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
||||
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
||||
import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseCard';
|
||||
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
||||
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
||||
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
||||
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||
import { cleanAxiosInstance } from '../../axiosApi';
|
||||
|
||||
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
|
||||
import LogInNotificationCard from 'components/sections/dashboard/Home/Property/LogInNotificationCard';
|
||||
|
||||
const PublicPropertyDetail: React.FC = () => {
|
||||
// In a real app, you'd get propertyId from URL params or a global state
|
||||
|
||||
const { propertyId } = useParams<{ propertyId: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const [property, setProperty] = useState<PropertiesAPI | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const getProperty = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = `/public/${propertyId}/`;
|
||||
const { data }: AxiosResponse<PropertiesAPI> = await cleanAxiosInstance.get(url);
|
||||
|
||||
if (data !== undefined) {
|
||||
setProperty(data);
|
||||
}
|
||||
await cleanAxiosInstance.post(`/public/${propertyId}/increment_view_count/`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError('Property not found.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getProperty();
|
||||
}, [propertyId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading property details...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Alert severity="info">No property data available.</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if the current user is the owner of this property
|
||||
const isOwnerOfProperty = false;
|
||||
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||
|
||||
console.log('here');
|
||||
console.log(property);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Property Details */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<PropertyDetailCard
|
||||
property={property}
|
||||
isPublicPage={true}
|
||||
onSave={null}
|
||||
isOwnerView={isOwnerOfProperty}
|
||||
onDelete={() => null}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Status, Cost, Offers */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<PropertyStatusCard
|
||||
property={property}
|
||||
isOwner={isOwnerOfProperty}
|
||||
onStatusChange={null}
|
||||
onSavedPropertySave={null}
|
||||
savedProperty={null}
|
||||
sellerDisclosureExists={null}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<LogInNotificationCard />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<OpenHouseCard openHouses={property.open_houses} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Information */}
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<WalkScoreCard walkScore={property.walk_score} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{property.schools && <SchoolCard schools={property.schools} />}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicPropertyDetail;
|
||||
180
ditch-the-agent/src/pages/Property/PublicPropertySearch.tsx
Normal file
180
ditch-the-agent/src/pages/Property/PublicPropertySearch.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Box,
|
||||
Grid,
|
||||
Alert,
|
||||
Stack,
|
||||
Link,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { PropertiesAPI } from 'types';
|
||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import MapSerachComponent from 'components/base/MapSearchComponent';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { cleanAxiosInstance } from '../../axiosApi';
|
||||
import { useMapsLibrary } from '@vis.gl/react-google-maps';
|
||||
|
||||
const PublicPropertySearchPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null);
|
||||
const [mapState, setMapState] = useState({
|
||||
center: { lat: 39.8283, lng: -98.5795 }, // Center of the US
|
||||
zoom: 4,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProperties = async () => {
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await cleanAxiosInstance.get(`/public/`);
|
||||
console.log(data);
|
||||
data.map((item) => {
|
||||
console.log(item);
|
||||
});
|
||||
if (data !== undefined) {
|
||||
setSearchResults(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoad(true);
|
||||
}
|
||||
};
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
const filterProperties = async (filters: any) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
for (const key in filters) {
|
||||
if (filters.hasOwnProperty(key)) {
|
||||
const value = filters[key];
|
||||
|
||||
// Exclude attributes that don't have values (null, undefined, empty string)
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
searchParams.append(key, String(value)); // Ensure value is a string
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
console.log(queryString);
|
||||
try {
|
||||
const { data }: AxiosResponse<PropertiesAPI[]> = await cleanAxiosInstance.get(
|
||||
`/public/?${queryString}`,
|
||||
);
|
||||
console.log(data);
|
||||
setSearchResults(data);
|
||||
} catch {
|
||||
} finally {
|
||||
setInitialLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (filters: any) => {
|
||||
console.log(filters);
|
||||
await filterProperties(filters);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchResults([]); // Clear results on clear
|
||||
setInitialLoad(true); // Reset to initial state
|
||||
};
|
||||
|
||||
const handleMapBoundsChange = ({ center, zoom, bounds }: any) => {
|
||||
setMapState({ center, zoom });
|
||||
// Optional: you could filter search results based on the map bounds
|
||||
};
|
||||
const handleMapMarkerClick = (propertyId: number) => {
|
||||
navigate(`/properties/${propertyId}/&search=1`);
|
||||
};
|
||||
|
||||
const handleBoxDrawn = (bounds: any) => {
|
||||
const filtered = mockProperties.filter((p) => {
|
||||
if (!p.latitude || !p.longitude) return false;
|
||||
return (
|
||||
p.latitude <= bounds.ne.lat &&
|
||||
p.latitude >= bounds.sw.lat &&
|
||||
p.longitude <= bounds.ne.lng &&
|
||||
p.longitude >= bounds.sw.lng
|
||||
);
|
||||
});
|
||||
setSearchResults(filtered);
|
||||
setInitialLoad(false);
|
||||
// Optional: Adjust map zoom to fit the new search results
|
||||
// You'd need to calculate the new center and zoom based on the filtered results.
|
||||
};
|
||||
|
||||
const handleMarkerClick = (propertyId: number) => {
|
||||
navigate(`/${propertyId}`);
|
||||
};
|
||||
|
||||
const handleMarkerHover = (property: PropertiesAPI) => {
|
||||
setSelectedPropertyId(property.id);
|
||||
};
|
||||
|
||||
const handleMarkerUnhover = () => {
|
||||
setSelectedPropertyId(null);
|
||||
};
|
||||
console.log('EHRER i samsdaf;l');
|
||||
|
||||
console.log(searchResults);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||
Property Search & Map
|
||||
</Typography>
|
||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Property List Section */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
||||
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
||||
</Typography>
|
||||
{searchResults.length === 0 ? (
|
||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||
) : (
|
||||
searchResults.map((property) => (
|
||||
<PropertyListItem
|
||||
key={property.id}
|
||||
property={property}
|
||||
isPublic={true}
|
||||
onHover={handleMarkerHover}
|
||||
onUnhover={handleMarkerUnhover}
|
||||
// The click logic will now be handled by the marker
|
||||
// We just highlight the selected marker
|
||||
isSelected={selectedPropertyId === property.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
{/* Map Section */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MapSerachComponent
|
||||
center={mapState.center}
|
||||
zoom={mapState.zoom}
|
||||
properties={searchResults}
|
||||
selectedPropertyId={selectedPropertyId}
|
||||
onBoundsChanged={handleMapBoundsChange}
|
||||
onBoxDrawn={handleBoxDrawn}
|
||||
onMarkerClick={handleMapMarkerClick} // Pass the navigation handler
|
||||
onMarkerHover={handleMarkerHover}
|
||||
onMarkerUnhover={handleMarkerUnhover}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicPropertySearchPage;
|
||||
84
ditch-the-agent/src/pages/Support/FAQ.tsx
Normal file
84
ditch-the-agent/src/pages/Support/FAQ.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaqApi } from 'types';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Paper,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import PageLoader from 'components/loading/PageLoader';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
|
||||
const FAQPage: React.FC = () => {
|
||||
const [faqs, setFaqs] = useState<FaqApi[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFaqs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data }: AxiosResponse<FaqApi[]> = await axiosInstance.get(`/support/faq/`);
|
||||
// Sort FAQs by order
|
||||
const sortedFaqs = data.sort((a, b) => a.order - b.order);
|
||||
setFaqs(sortedFaqs);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFaqs();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Typography variant="h6" color="error">
|
||||
Failed to load FAQs. Please try again later.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Paper sx={{ my: 4, p: 3 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Frequently Asked Questions
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Most questions can be answered by looking through the frequently asked questions below. If
|
||||
you still have issues, you can <Link to="/support/manager">submit an issue</Link>.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={3} sx={{ p: 2 }}>
|
||||
{faqs.map((faq) => (
|
||||
<Accordion key={faq.order}>
|
||||
<AccordionSummary expandIcon={<IconifyIcon icon="mdi:chevron-down" />}>
|
||||
<Typography variant="h6">{faq.question}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{faq.answer}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQPage;
|
||||
418
ditch-the-agent/src/pages/Support/SupportManager.tsx
Normal file
418
ditch-the-agent/src/pages/Support/SupportManager.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import PageLoader from 'components/loading/PageLoader';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Stack,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { SupportCaseApi, SupportMessageApi } from 'types';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import { formatTimestamp } from 'utils';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import QuestionMarkOutlined from '@mui/icons-material/QuestionMarkOutlined';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import CreateSupportCaseDialogContent from 'components/sections/dashboard/Home/Support/CreateSupportCaseDialogContent';
|
||||
|
||||
interface SupportManagerProps {}
|
||||
|
||||
const SupportManager: React.FC<SupportManagerProps> = ({}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { account } = useContext(AccountContext);
|
||||
const [supportCases, setSupportCases] = useState<SupportCaseApi[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const [selectedSupportCaseId, setSelectedSupportCaseId] = useState<number | null>(null);
|
||||
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [errorMessages, setErrorMssages] = useState(false);
|
||||
const [supportCase, setSupportCase] = useState<SupportCaseApi>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedSupportCase = searchParams.get('selectedSupportCase');
|
||||
if (selectedSupportCase) {
|
||||
console.log(selectedSupportCase);
|
||||
setSelectedSupportCaseId(parseInt(selectedSupportCase, 10));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSupportCases = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
const { data }: AxiosResponse<SupportCaseApi[]> =
|
||||
await axiosInstance.get('/support/cases/');
|
||||
const data_with_messages: SupportCaseApi[] = data;
|
||||
for (let i = 0; i < data_with_messages.length; i++) {
|
||||
if (data_with_messages[i].messages === undefined) {
|
||||
data_with_messages[i].messages = [
|
||||
{
|
||||
id: 0,
|
||||
text: data_with_messages[i].description,
|
||||
created_at: data_with_messages[i].created_at,
|
||||
updated_at: data_with_messages[i].updated_at,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
setSupportCases(data_with_messages);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSupportCases();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
setLoadingMessages(true);
|
||||
setErrorMssages(false);
|
||||
const { data }: AxiosResponse<SupportCaseApi> = await axiosInstance.get(
|
||||
`/support/cases/${selectedSupportCaseId}/`,
|
||||
);
|
||||
console.log(data);
|
||||
// 1. Create a new SupportMessageApi object from the description
|
||||
const descriptionMessage: SupportMessageApi = {
|
||||
// You'll need to decide on values for these fields.
|
||||
// Since this is a synthetic message based on the case description,
|
||||
// you might use special values like -1 for id and 'System' for the user fields.
|
||||
id: -1, // Use a unique or sentinel value
|
||||
text: data.description,
|
||||
user: -1, // Sentinel user ID
|
||||
user_first_name: data.title, // Use the title as the sender/label
|
||||
user_last_name: 'Description',
|
||||
created_at: data.created_at, // Use the case's creation date
|
||||
updated_at: data.created_at,
|
||||
};
|
||||
|
||||
// 2. Create the updated SupportCaseApi object
|
||||
const updatedSupportCase: SupportCaseApi = {
|
||||
...data, // Keep all existing case data
|
||||
// Prepend the new description message to the existing messages array
|
||||
messages: [descriptionMessage, ...data.messages],
|
||||
};
|
||||
setSupportCase(updatedSupportCase);
|
||||
} catch (error) {
|
||||
setErrorMssages(true);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
};
|
||||
fetchMessages();
|
||||
}, [selectedSupportCaseId]);
|
||||
|
||||
const handleOpenCreateSupportCaseDialog = () => {
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseCreateSupportDialog = () => {
|
||||
setShowDialog(false);
|
||||
setSelectedSupportCaseId(null);
|
||||
};
|
||||
|
||||
const handleCreateSupportCase = async (
|
||||
supportCase: Omit<SupportCaseApi, 'id' | 'status' | 'messages' | 'created_at' | 'updated_at'>,
|
||||
) => {
|
||||
try {
|
||||
const response: AxiosResponse<SupportCaseApi> = await axiosInstance.post(
|
||||
'/support/cases/',
|
||||
supportCase,
|
||||
);
|
||||
setSupportCases((prevCases) => [...prevCases, response.data]);
|
||||
handleCloseCreateSupportDialog();
|
||||
setSelectedSupportCaseId(response.data.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to create support case', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sending a new message
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessageContent.trim() || !selectedSupportCaseId) {
|
||||
return; // Don't send empty messages or if no conversation is selected
|
||||
}
|
||||
|
||||
// send the message to the backend
|
||||
try {
|
||||
const { data }: AxiosResponse<SupportMessageApi> = await axiosInstance.post(
|
||||
`/support/messages/`,
|
||||
{
|
||||
user: account?.id,
|
||||
text: newMessageContent.trim(),
|
||||
support_case: selectedSupportCaseId,
|
||||
},
|
||||
);
|
||||
|
||||
setSupportCases((prevConversations) =>
|
||||
prevConversations.map((conv) =>
|
||||
conv.id === selectedSupportCaseId
|
||||
? {
|
||||
...conv,
|
||||
messages: [...conv.messages, data],
|
||||
updated_at: data.updated_at,
|
||||
}
|
||||
: conv,
|
||||
),
|
||||
);
|
||||
setNewMessageContent(''); // Clear the input field
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Typography variant="h6" color="error">
|
||||
Failed to load Support Cases. Please try again later.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSupportCase = supportCases.find((item) => item.id === selectedSupportCaseId);
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||
>
|
||||
<Grid container sx={{ height: '100%', width: '100%' }}>
|
||||
{/* Left Panel: Conversation 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' }}>
|
||||
Support Cases
|
||||
</Typography>
|
||||
{account?.user_type === 'property_owner' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
onClick={handleOpenCreateSupportCaseDialog}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||
{supportCases.length === 0 ? (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||
<QuestionMarkOutlined sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography>No support cases yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
supportCases
|
||||
.sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
selected={selectedSupportCaseId === conv.id}
|
||||
onClick={() => setSelectedSupportCaseId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||
{conv.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1, pr: 1 }}
|
||||
>
|
||||
{conv.status}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimestamp(conv.updated_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Conversation Detail */}
|
||||
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{supportCase ? (
|
||||
<>
|
||||
{/* Support Case Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/*<Avatar sx={{ bgcolor: 'purple.200' }}>
|
||||
{supportCase.title
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</Avatar>*/}
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{supportCase.title} | {supportCase.category}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
{supportCase.messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: message.user === account?.id ? 'flex-end' : 'flex-start',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: '75%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: message.user === account?.id ? 'purple.200' : 'lightblue.50',
|
||||
color: 'grey.800',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'medium', mb: 0.5 }}>
|
||||
{message.user === account?.id ? 'You' : supportCase.withName}
|
||||
</Typography>
|
||||
<Typography variant="body1">{message.text}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', textAlign: 'right', mt: 0.5 }}
|
||||
>
|
||||
{formatTimestamp(message.updated_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<div ref={messagesEndRef} /> {/* Scroll target */}
|
||||
</Box>
|
||||
|
||||
{/* Message Input */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Type your message..."
|
||||
value={newMessageContent}
|
||||
onChange={(e) => setNewMessageContent(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSendMessage}
|
||||
disabled={selectedSupportCaseId === null || supportCase.status !== 'opened'}
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ px: 3, py: 1.2 }}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 3,
|
||||
color: 'grey.500',
|
||||
}}
|
||||
>
|
||||
<QuestionMarkOutlined sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6">Select a support case to view messages</Typography>
|
||||
<Typography variant="body2">
|
||||
Click on a support case from the left panel to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
<CreateSupportCaseDialogContent
|
||||
showDialog={showDialog}
|
||||
closeDialog={handleCloseCreateSupportDialog}
|
||||
createSupportCase={handleCreateSupportCase}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportManager;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { ReactElement, useEffect, useState, useMemo } from 'react';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
|
||||
import DashboardTemplate from 'components/DasboardTemplate';
|
||||
@@ -8,10 +8,145 @@ import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
const base_url: string = `${import.meta.env.VITE_API_URL?.replace('/api/', '')}/media/vendor_pictures/`;
|
||||
|
||||
// Define the array of corrected and alphabetized categories with 'as const'
|
||||
const CATEGORY_NAMES = [
|
||||
'Arborist',
|
||||
'Basement Waterproofing And Injection',
|
||||
'Carpenter',
|
||||
'Cleaning Company',
|
||||
'Decking',
|
||||
'Door Company',
|
||||
'Electrician',
|
||||
'Fencing',
|
||||
'General Contractor',
|
||||
'Handyman',
|
||||
'Home Inspector',
|
||||
'House Staging',
|
||||
'HVAC',
|
||||
'Irrigation And Sprinkler System',
|
||||
'Junk Removal',
|
||||
'Landscaping',
|
||||
'Masonry',
|
||||
'Mortgage Lendor',
|
||||
'Moving Company',
|
||||
'Painter',
|
||||
'Paving Company',
|
||||
'Pest Control',
|
||||
'Photographer',
|
||||
'Plumber',
|
||||
'Pressure Washing',
|
||||
'Roofer',
|
||||
'Storage Facility',
|
||||
'Window Company',
|
||||
'Window Washing',
|
||||
] as const;
|
||||
|
||||
const defaultCategoryImages: { [key in VendorType]: string } = {
|
||||
Arborist: `${base_url}arborist.png`,
|
||||
'Basement Waterproofing And Injection': `${base_url}basement.png`,
|
||||
Carpenter: `${base_url}carpenter.png`,
|
||||
'Cleaning Company': `${base_url}cleaning.png`,
|
||||
Decking: `${base_url}deck.png`,
|
||||
'Door Company': `${base_url}door.png`,
|
||||
Electrician: `${base_url}electrician.png`,
|
||||
Fencing: `${base_url}fencing.png`,
|
||||
'General Contractor': `${base_url}general_contractor.png`,
|
||||
Handyman: `${base_url}handyman.png`,
|
||||
'Home Inspector': `${base_url}home_inspector.png`,
|
||||
'House Staging': `${base_url}home_staging.png`,
|
||||
HVAC: `${base_url}hvac.png`,
|
||||
'Irrigation And Sprinkler System': `${base_url}irrigation.png`,
|
||||
'Junk Removal': `${base_url}junk_removal.png`,
|
||||
Landscaping: `${base_url}landscape.png`,
|
||||
'Mortgage Lendor': `${base_url}landscape.png`,
|
||||
Masonry: `${base_url}masonry.png`,
|
||||
'Moving Company': `${base_url}junk_removal.png`,
|
||||
Painter: `${base_url}painting.png`,
|
||||
'Paving Company': `${base_url}paving.png`,
|
||||
'Pest Control': `${base_url}pest_control.png`,
|
||||
Photographer: `${base_url}photography.png`,
|
||||
Plumber: `${base_url}plumber.jpg`,
|
||||
'Pressure Washing': `${base_url}power_washing.png`,
|
||||
Roofer: `${base_url}roofer.png`,
|
||||
'Storage Facility': `${base_url}storage.png`,
|
||||
'Window Company': `${base_url}window_installation.png`,
|
||||
'Window Washing': `${base_url}window_company.jpg`,
|
||||
};
|
||||
|
||||
const CATEGRORY_DESCRIPTIONS: { [key in VendorType]: string } = {
|
||||
Arborist:
|
||||
'Connect with certified tree specialists for professional tree pruning, health assessment, and safe removal services. Keep your trees beautiful and your property protected.',
|
||||
'Basement Waterproofing And Injection':
|
||||
'Protect your biggest investment by finding experts in basement leak repair, foundation sealing, and effective moisture control solutions. Say goodbye to damp and musty spaces.',
|
||||
Carpenter:
|
||||
'Hire skilled craftsmen for custom built-ins, framing, trim work, and detailed wood repair projects around your home. Bring your vision of custom woodworking to life.',
|
||||
'Cleaning Company': `Find reliable residential and commercial cleaning services to maintain a spotless and healthy environment. Whether it's a deep clean or regular maintenance, they handle the dirty work.`,
|
||||
Decking:
|
||||
'Design and build the perfect outdoor living space with professionals specializing in new deck construction, repairs, and staining. Get ready to enjoy your backyard retreat.',
|
||||
'Door Company': `Install beautiful and secure entry, patio, and interior doors that enhance your home's curb appeal and energy efficiency. Upgrade your home's look and security.`,
|
||||
Electrician:
|
||||
'Get trusted service for wiring upgrades, lighting installation, electrical repairs, and safety inspections from licensed professionals. Power your home safely and efficiently.',
|
||||
Fencing:
|
||||
'Secure your property and enhance privacy with experts in wood, vinyl, metal, and chain-link fence installation and repair. Define your boundaries with style.',
|
||||
'General Contractor':
|
||||
'Oversee your entire home remodel, new construction, or major renovation project with a single trusted project manager. They coordinate all trades to complete your vision seamlessly.',
|
||||
Handyman:
|
||||
'For those small repairs and maintenance tasks that pile up, find a versatile professional to handle various jobs quickly and efficiently. Check off your to-do list with ease.',
|
||||
'Home Inspector': `Get a detailed, unbiased report on the condition of a potential home purchase or sale from a certified inspector. Know exactly what you're buying or selling before you close.`,
|
||||
'House Staging': `Prepare your home to sell faster and for a higher price with professional staging services that highlight your home's best features. Make a stunning first impression on potential buyers.`,
|
||||
HVAC: 'Keep your home comfortable year-round with experts in heating, ventilation, and air conditioning system repair, maintenance, and new installation. Control your climate efficiently.',
|
||||
'Irrigation And Sprinkler System': `Install, repair, and maintain efficient sprinkler systems that keep your lawn and garden healthy without wasting water. Automate your yard's watering needs.`,
|
||||
'Junk Removal':
|
||||
'Clear out clutter, construction debris, or unwanted items with fast and responsible hauling and disposal services. Reclaim your space and let them handle the heavy lifting.',
|
||||
Landscaping:
|
||||
'Transform your yard with design, installation, and maintenance services for beautiful lawns, gardens, and outdoor features. Create the perfect curb appeal and living space outside.',
|
||||
Masonry:
|
||||
'Restore or construct durable, attractive structures using brick, stone, and concrete for patios, walls, fireplaces, and foundations. Find skilled artisans for lasting results.',
|
||||
'Mortgage Lendor':
|
||||
'Connect with financing professionals to guide you through the process of securing a new home loan or refinancing your current one. Find the best rates and terms for your financial needs.',
|
||||
'Moving Company':
|
||||
'Hire reliable and insured movers for local or long-distance relocation, packing, and secure transportation of your belongings. Enjoy a smooth, stress-free move to your new home.',
|
||||
Painter: `Refresh your home's interior and exterior with professional painting services, including prep work, color consultation, and a flawless finish. Give your home a vibrant new look.`,
|
||||
'Paving Company':
|
||||
'Install and repair durable asphalt and concrete surfaces for driveways, walkways, and patios. Find experts to enhance the accessibility and curb appeal of your property.',
|
||||
'Pest Control':
|
||||
'Protect your home from unwanted guests like insects, rodents, and wildlife with effective, preventative, and removal treatments. Keep your home safe and pest-free.',
|
||||
Photographer:
|
||||
'Capture stunning, high-quality images of your property for real estate listings, rentals, or design portfolios. Professional photos make a significant difference in marketing your home.',
|
||||
Plumber:
|
||||
'For leaks, clogs, fixture installation, and water heater repair, connect with licensed plumbing professionals for reliable service. Ensure your water systems are running smoothly.',
|
||||
'Pressure Washing': `Revitalize your home's exterior, driveway, deck, and siding by removing dirt, grime, mold, and mildew. Bring back the original sparkle and brightness to your property.`,
|
||||
Roofer:
|
||||
'Find expert contractors for new roof installation, leak repair, and routine inspections to protect your home from the elements. Secure your home with a reliable, durable roof.',
|
||||
'Storage Facility':
|
||||
'Locate secure, convenient, and affordable short-term or long-term storage solutions for your personal or business belongings. Declutter your space without throwing things away.',
|
||||
'Window Company':
|
||||
'Upgrade your home with energy-efficient window replacement and installation to improve comfort and lower utility bills. Find the perfect styles to enhance natural light and aesthetics.',
|
||||
'Window Washing':
|
||||
'Schedule professional interior and exterior window cleaning to achieve streak-free, crystal-clear views. Let the natural light flood your home and boost your curb appeal.',
|
||||
} as const;
|
||||
|
||||
export type VendorType = (typeof CATEGORY_NAMES)[number];
|
||||
const Vendors = (): ReactElement => {
|
||||
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
|
||||
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
|
||||
const [hideEmptyCategories, setHideEmptyCategories] = useState<boolean>(true); // Default to true
|
||||
const [searchRadius, setSearchRadius] = useState<number>(10);
|
||||
|
||||
// Simulate fetching data
|
||||
let fetchedVendors: VendorItem[] = [];
|
||||
@@ -51,42 +186,26 @@ const Vendors = (): ReactElement => {
|
||||
}
|
||||
>();
|
||||
|
||||
const defaultCategoryImages: { [key: string]: string } = {
|
||||
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician',
|
||||
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber',
|
||||
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping',
|
||||
};
|
||||
CATEGORY_NAMES.forEach((categoryName) => {
|
||||
const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
description: CATEGRORY_DESCRIPTIONS[categoryName],
|
||||
imageUrl:
|
||||
defaultCategoryImages[categoryName] ||
|
||||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
numVendors: 0,
|
||||
totalRating: 0,
|
||||
});
|
||||
});
|
||||
|
||||
fetchedVendors.forEach((vendor) => {
|
||||
const categoryId = vendor.categoryId;
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
let categoryName = '';
|
||||
switch (categoryId) {
|
||||
case 'electrician':
|
||||
categoryName = 'Electricians';
|
||||
break;
|
||||
case 'plumber':
|
||||
categoryName = 'Plumbers';
|
||||
break;
|
||||
case 'landscaping':
|
||||
categoryName = 'Landscaping';
|
||||
break;
|
||||
default:
|
||||
categoryName = 'Other Service';
|
||||
}
|
||||
categoryMap.set(categoryId, {
|
||||
name: categoryName,
|
||||
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
|
||||
imageUrl:
|
||||
defaultCategoryImages[categoryId] ||
|
||||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||
numVendors: 0,
|
||||
totalRating: 0,
|
||||
});
|
||||
if (categoryMap.has(categoryId)) {
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.numVendors += 1;
|
||||
categoryData.totalRating += vendor.rating;
|
||||
}
|
||||
const categoryData = categoryMap.get(categoryId)!;
|
||||
categoryData.numVendors += 1;
|
||||
categoryData.totalRating += vendor.rating;
|
||||
});
|
||||
|
||||
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
|
||||
@@ -105,37 +224,110 @@ const Vendors = (): ReactElement => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
// 1. Hide empty categories if the toggle is set to true
|
||||
let categoriesToRender = vendorCategories;
|
||||
console.log(vendorCategories);
|
||||
if (hideEmptyCategories) {
|
||||
categoriesToRender = vendorCategories.filter((cat) => cat.numVendors > 0);
|
||||
}
|
||||
|
||||
// 2. Geographic filtering based on searchRadius would go here.
|
||||
// This would typically involve user location and an API call/client-side distance calculation
|
||||
// filteredCategories = categoriesToRender.filter(category =>
|
||||
// category.vendors.some(vendor => isWithinRadius(vendor.location, userLocation, searchRadius))
|
||||
// );
|
||||
|
||||
return categoriesToRender;
|
||||
}, [vendorCategories, hideEmptyCategories, searchRadius]);
|
||||
|
||||
return (
|
||||
<DashboardTemplate<VendorCategory, VendorItem>
|
||||
pageTitle="Service Vendors"
|
||||
data={{ categories: vendorCategories, items: allVendors }}
|
||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||
<CategoryGridTemplate
|
||||
categories={categories}
|
||||
onSelectCategory={(id) => onSelectCategory(id)}
|
||||
renderCategoryCard={(category, onSelect) => (
|
||||
<VendorCategoryCard category={category as VendorCategory} onSelectCategory={onSelect} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||
<ItemListDetailTemplate
|
||||
category={selectedCategory}
|
||||
items={itemsInSelectedCategory}
|
||||
onBack={onBack}
|
||||
renderListItem={(item, isSelected, onSelect) => (
|
||||
<VendorListItem
|
||||
vendor={item as VendorItem}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
renderItemDetail={(item) => (
|
||||
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
{/* VENDOR FILTERS HEADER AND CONTROLS */}
|
||||
<Paper
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||
Vendor Filters
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
mt: { xs: 1, sm: 0 },
|
||||
}}
|
||||
>
|
||||
{/* TOGGLE: Hide Categories with No Vendors */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={hideEmptyCategories}
|
||||
onChange={(e) => setHideEmptyCategories(e.target.checked)}
|
||||
name="hideEmpty"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Hide Empty Categories"
|
||||
/>
|
||||
|
||||
{/* DROPDOWN: Search Radius */}
|
||||
{/*<FormControl variant="outlined" sx={{ minWidth: 120 }} size="small">
|
||||
<InputLabel id="search-radius-label">Search Radius</InputLabel>
|
||||
<Select
|
||||
labelId="search-radius-label"
|
||||
id="search-radius-select"
|
||||
value={searchRadius}
|
||||
onChange={(e) => setSearchRadius(e.target.value as number)}
|
||||
label="Search Radius"
|
||||
>
|
||||
<MenuItem value={10}>10 miles</MenuItem>
|
||||
<MenuItem value={25}>25 miles</MenuItem>
|
||||
<MenuItem value={50}>50 miles</MenuItem>
|
||||
</Select>
|
||||
</FormControl>*/}
|
||||
</Box>
|
||||
</Paper>
|
||||
<DashboardTemplate<VendorCategory, VendorItem>
|
||||
pageTitle="Service Vendors"
|
||||
data={{ categories: filteredCategories, items: allVendors }}
|
||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||
<CategoryGridTemplate
|
||||
categories={categories}
|
||||
onSelectCategory={(id) => onSelectCategory(id)}
|
||||
renderCategoryCard={(category, onSelect) => (
|
||||
<VendorCategoryCard
|
||||
category={category as VendorCategory}
|
||||
onSelectCategory={onSelect}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||
<ItemListDetailTemplate
|
||||
category={selectedCategory}
|
||||
items={itemsInSelectedCategory}
|
||||
onBack={onBack}
|
||||
renderListItem={(item, isSelected, onSelect) => (
|
||||
<VendorListItem
|
||||
vendor={item as VendorItem}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
renderItemDetail={(item) => (
|
||||
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import loginBanner from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import{axiosInstance} from '../../axiosApi.js';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { AuthContext } from 'contexts/AuthContext.js';
|
||||
@@ -24,8 +24,7 @@ import { AuthContext } from 'contexts/AuthContext.js';
|
||||
type loginValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const Login = (): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -34,36 +33,31 @@ const Login = (): ReactElement => {
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {setAuthentication} = useContext(AuthContext);
|
||||
const { setAuthentication } = useContext(AuthContext);
|
||||
|
||||
const handleLogin = async({email, password}: loginValues): Promise<void> => {
|
||||
try{
|
||||
const response = await axiosInstance.post('/token/',
|
||||
{
|
||||
email: email,
|
||||
password: password
|
||||
}
|
||||
)
|
||||
const handleLogin = async ({ email, password }: loginValues): Promise<void> => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/token/', {
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
localStorage.setItem('refresh_token', response.data.refresh);
|
||||
const get_user_response = await axiosInstance.get('/user/')
|
||||
const get_user_response = await axiosInstance.get('/user/');
|
||||
|
||||
setAuthentication(true)
|
||||
setAuthentication(true);
|
||||
|
||||
navigate("/")
|
||||
|
||||
|
||||
|
||||
}catch (error) {
|
||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||
if (hasErrors) {
|
||||
setErrorMessage(error.response.data)
|
||||
}else{
|
||||
setErrorMessage(error.response.data);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -85,97 +79,98 @@ const Login = (): ReactElement => {
|
||||
password: '',
|
||||
}}
|
||||
onSubmit={handleLogin}
|
||||
>
|
||||
{({setFieldValue}) => (
|
||||
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
{errorMessage ? (
|
||||
<Alert severity='error'>
|
||||
>
|
||||
{({ setFieldValue }) => (
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
{errorMessage ? (
|
||||
<Alert severity="error">
|
||||
{errorMessage.detail ? (
|
||||
<Typography>{errorMessage.detail}</Typography>
|
||||
) : (
|
||||
<ul>
|
||||
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||
<li key={fieldName}>
|
||||
<strong>{fieldName}</strong>
|
||||
{errorMessages.length > 0 ? (
|
||||
<ul>
|
||||
{errorMessages.map((message, index) => (
|
||||
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span> No specific errors for this field.</span>
|
||||
)}
|
||||
{Array.isArray(errorMessages) ? (
|
||||
<ul>
|
||||
{errorMessages.map((message, index) => (
|
||||
<li key={`${fieldName}-${index}`}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span>: {String(errorMessages)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</Alert>
|
||||
|
||||
): null}
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
alignSelf: 'flex-end',
|
||||
)}
|
||||
</Alert>
|
||||
) : null}
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Link href="/authentication/forgot-password" underline="hover">
|
||||
Forget password
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant="contained" type={'submit'} fullWidth>
|
||||
Log in
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconifyIcon icon="ic:baseline-key-off" />
|
||||
) : (
|
||||
<IconifyIcon icon="ic:baseline-key" />
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Link href="/authentication/forgot-password" underline="hover">
|
||||
Forget password
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant="contained" type={'submit'} fullWidth>
|
||||
Log in
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Don't have an account ?{' '}
|
||||
|
||||
@@ -17,10 +17,12 @@ import passwordUpdated from 'assets/authentication-banners/password-updated.png'
|
||||
import successTick from 'assets/authentication-banners/successTick.png';
|
||||
import Image from 'components/base/Image';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
|
||||
|
||||
const ResetPassword = (): ReactElement => {
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
|
||||
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
|
||||
@@ -65,6 +67,7 @@ const ResetPassword = (): ReactElement => {
|
||||
placeholder="Enter new password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
id="new-password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -86,6 +89,7 @@ const ResetPassword = (): ReactElement => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<PasswordStrengthChecker password={password} />
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="confirm-password">
|
||||
|
||||
@@ -22,6 +22,7 @@ import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type SignUpValues = {
|
||||
@@ -36,6 +37,7 @@ type SignUpValues = {
|
||||
const SignUp = (): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||
|
||||
@@ -217,7 +219,10 @@ const SignUp = (): ReactElement => {
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="********"
|
||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||
onChange={(event) => {
|
||||
setFieldValue('password', event.target.value);
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
InputProps={{
|
||||
@@ -241,6 +246,7 @@ const SignUp = (): ReactElement => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<PasswordStrengthChecker password={password} />
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="password">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const rootPaths = {
|
||||
homeRoot: '',
|
||||
dashboardRoot: 'dashboard',
|
||||
pagesRoot: 'pages',
|
||||
applicationsRoot: 'applications',
|
||||
ecommerceRoot: 'ecommerce',
|
||||
@@ -16,12 +17,17 @@ export const rootPaths = {
|
||||
profileRoot: 'profile',
|
||||
offersRoot: 'offers',
|
||||
bidsRoot: 'bids',
|
||||
documentsRoot: 'documents',
|
||||
vendorBidsRoot: 'vendor-bids',
|
||||
upgradeRoot: 'upgrade',
|
||||
propertySearchRoot: 'property-search',
|
||||
supportRoot: 'support',
|
||||
publicRoot: 'public',
|
||||
};
|
||||
|
||||
export default {
|
||||
home: `/${rootPaths.homeRoot}`,
|
||||
dashboard: `/${rootPaths.dashboardRoot}`,
|
||||
login: `/${rootPaths.authRoot}/login`,
|
||||
signup: `/${rootPaths.authRoot}/sign-up`,
|
||||
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
||||
@@ -31,7 +37,9 @@ export default {
|
||||
educationLesson: `/${rootPaths.educationRoot}/lesson`,
|
||||
property: `/${rootPaths.propertyRoot}`,
|
||||
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
|
||||
propertySearch: `/${rootPaths.propertyRoot}/search`,
|
||||
propertySearch: `/${rootPaths.propertySearchRoot}`,
|
||||
publicPropertySearch: `/${rootPaths.homeRoot}`,
|
||||
publicPropertyDetail: `/${rootPaths.publicRoot}/:propertyId`,
|
||||
vendors: `/${rootPaths.vendorsRoot}`,
|
||||
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
|
||||
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
|
||||
@@ -43,6 +51,9 @@ export default {
|
||||
bids: `/${rootPaths.bidsRoot}/`,
|
||||
vendorBids: `/${rootPaths.vendorBidsRoot}/`,
|
||||
upgrade: `/${rootPaths.upgradeRoot}/`,
|
||||
documents: `/${rootPaths.documentsRoot}/`,
|
||||
support: `/${rootPaths.supportRoot}/`,
|
||||
supportManager: `/${rootPaths.supportRoot}/manager/`,
|
||||
|
||||
// need to do these pages
|
||||
profile: `/${rootPaths.profileRoot}/`,
|
||||
|
||||
@@ -29,9 +29,25 @@ import ProfilePage from 'pages/Profile/Profile';
|
||||
import Dashboard from 'pages/home/Dashboard';
|
||||
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
|
||||
import PropertySearchPage from 'pages/Property/PropertySearchPage';
|
||||
import PublicPropertySearch from 'pages/Property/PublicPropertySearch';
|
||||
import BidsPage from 'pages/Bids/Bids';
|
||||
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
|
||||
import UpgradePage from 'pages/Upgrade/UpgradePage';
|
||||
import DocumentManager from 'components/sections/dashboard/Home/Documents/DocumentManager';
|
||||
import { Typography } from '@mui/material';
|
||||
import PublicPropertyDetail from 'pages/Property/PublicPropertyDetail';
|
||||
import FAQPage from 'pages/Support/FAQ';
|
||||
import SupportManager from 'pages/Support/SupportManager';
|
||||
|
||||
const RootRedirect = () => {
|
||||
const { authenticated } = useContext(AuthContext);
|
||||
|
||||
if (authenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <PublicPropertySearch />;
|
||||
};
|
||||
|
||||
const App = lazy(() => import('App'));
|
||||
const MainLayout = lazy(async () => {
|
||||
@@ -47,6 +63,13 @@ const AuthLayout = lazy(async () => {
|
||||
]).then(([moduleExports]) => moduleExports);
|
||||
});
|
||||
|
||||
const PublicLayout = lazy(async () => {
|
||||
return Promise.all([
|
||||
import('layouts/public-layout'),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
]).then(([moduleExports]) => moduleExports);
|
||||
});
|
||||
|
||||
const Error404 = lazy(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return import('pages/errors/Error404');
|
||||
@@ -92,7 +115,7 @@ const routes: RouteObject[] = [
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: rootPaths.homeRoot,
|
||||
path: rootPaths.dashboardRoot,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
@@ -104,9 +127,41 @@ const routes: RouteObject[] = [
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.home,
|
||||
path: paths.dashboard,
|
||||
element: <Dashboard />,
|
||||
//element: <Sales />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.homeRoot,
|
||||
element: (
|
||||
<PublicLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</PublicLayout>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.publicPropertySearch,
|
||||
element: <RootRedirect />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: rootPaths.publicRoot,
|
||||
element: (
|
||||
<PublicLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</PublicLayout>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.publicPropertyDetail,
|
||||
element: <PublicPropertyDetail />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -175,6 +230,20 @@ const routes: RouteObject[] = [
|
||||
path: paths.propertyDetail,
|
||||
element: <PropertyDetailPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.propertySearchRoot,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.propertySearch,
|
||||
element: <PropertySearchPage />,
|
||||
@@ -259,6 +328,25 @@ const routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.documentsRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.documents,
|
||||
element: <DocumentManager />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.bidsRoot,
|
||||
|
||||
@@ -316,6 +404,29 @@ const routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.supportRoot,
|
||||
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: paths.support,
|
||||
element: <FAQPage />,
|
||||
},
|
||||
{
|
||||
path: paths.supportManager,
|
||||
element: <SupportManager />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: rootPaths.profileRoot,
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// src/templates/types.ts
|
||||
|
||||
import { HomeImprovementReceiptData } from 'components/sections/dashboard/Home/Documents/Dialog/HomeImprovementReciptDialogContent';
|
||||
import { OfferData } from 'components/sections/dashboard/Home/Documents/Dialog/OfferDialogContent';
|
||||
import { SellerDisclousureData } from 'components/sections/dashboard/Home/Documents/Dialog/SellerDisclousureDialogContent';
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
path: string;
|
||||
@@ -105,6 +109,14 @@ export interface PropertyOwnerAPI {
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export interface OpenHouseAPI {
|
||||
id: number;
|
||||
property: number | PropertiesAPI; // It can be an ID or the full object
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
listed_date: string;
|
||||
}
|
||||
|
||||
export interface VendorAPI {
|
||||
user: UserAPI;
|
||||
business_name: string;
|
||||
@@ -222,10 +234,6 @@ export interface TaxHistoryAPI {
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface OpenHouseAPI {
|
||||
lsited_date: string;
|
||||
}
|
||||
|
||||
export interface SchoolAPI {
|
||||
id?: number;
|
||||
address: string;
|
||||
@@ -297,6 +305,7 @@ export interface PropertiesAPI {
|
||||
tax_info: TaxHistoryAPI;
|
||||
open_houses?: OpenHouseAPI[];
|
||||
schools: SchoolAPI[];
|
||||
documents?: DocumentAPI[];
|
||||
}
|
||||
|
||||
export interface BidImageAPI {
|
||||
@@ -327,6 +336,19 @@ export interface BidAPI {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentAPI {
|
||||
id: number;
|
||||
property: number;
|
||||
file: string;
|
||||
document_type: string;
|
||||
description?: string;
|
||||
uploaded_by: number; // or a more detailed user object
|
||||
shared_with: number[];
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
sub_document?: SellerDisclousureData | HomeImprovementReceiptData | OfferData;
|
||||
}
|
||||
|
||||
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
|
||||
owner: number;
|
||||
}
|
||||
@@ -711,4 +733,37 @@ export interface PropertyResponseAPI {
|
||||
compsLookupExecutionTimeMS: string;
|
||||
}
|
||||
|
||||
export interface SavedPropertiesAPI {
|
||||
id: number;
|
||||
user: number;
|
||||
property: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FaqApi {
|
||||
order: number;
|
||||
answer: string;
|
||||
question: string;
|
||||
}
|
||||
|
||||
export interface SupportMessageApi {
|
||||
id: number;
|
||||
text: string;
|
||||
user: id;
|
||||
user_first_name: string;
|
||||
user_last_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupportCaseApi {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: string;
|
||||
messages: SupportMessageApi[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
// Walk Score API Type Definitions
|
||||
|
||||
@@ -24,3 +24,16 @@ export function extractLatLon(pointString: string): { latitude: number; longitud
|
||||
}
|
||||
return { latitude: 0, longitude: 0 }; // Return null if the string format is not as expected or parsing fails
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number as a currency string using US dollar locale.
|
||||
*
|
||||
* @param value The number to format.
|
||||
* @returns A string representing the formatted currency value (e.g., "$1,234.56").
|
||||
*/
|
||||
export const formatCurrency = (value: number): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user