inital checkin before beta launch

This commit is contained in:
2025-10-15 15:09:37 -05:00
parent 86b1eaf6f7
commit a3675c2585
88 changed files with 8224 additions and 2367 deletions

View File

@@ -1 +1,2 @@
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w" REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
REAL_ESTATE_API_KEY=AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70

View File

@@ -0,0 +1,3 @@
VITE_API_URL=https://beta.backend.ditchtheagent.com/api/
ENABLE_REGISTRATION=true
USE_LIVE_DATA=false

View File

@@ -0,0 +1,3 @@
VITE_API_URL=http://127.0.0.1:8010/api/
ENABLE_REGISTRATION=true
USE_LIVE_DATA=false

View File

@@ -0,0 +1,3 @@
VITE_API_URL=https://backend.ditchtheagent.com/api/
ENABLE_REGISTRATION=false
USE_LIVE_DATA=true

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,59 @@
{ {
"name": "mui-dta-dashboard", "name": "mui-dta-dashboard",
"private": true, "private": true,
"version": "1.0.1", "version": "1.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "build:beta": "tsc && vite build --mode beta",
"preview": "vite preview", "build:prod": "tsc && vite build --mode production",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"deploy": "gh-pages -d dist" "preview": "vite preview",
}, "predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
"dependencies": { "deploy": "gh-pages -d dist"
"@emotion/react": "^11.11.4", },
"@emotion/styled": "^11.11.5", "dependencies": {
"@mui/material": "^5.15.14", "@emotion/react": "^11.11.4",
"@mui/x-data-grid": "^7.2.0", "@emotion/styled": "^11.11.5",
"@mui/x-data-grid-generator": "^7.2.0", "@mui/icons-material": "^7.3.2",
"@react-google-maps/api": "^2.20.7", "@mui/material": "^7.3.2",
"@vis.gl/react-google-maps": "^1.5.4", "@mui/x-data-grid": "^7.2.0",
"axios": "^1.10.0", "@mui/x-data-grid-generator": "^7.2.0",
"dayjs": "^1.11.10", "@mui/x-date-pickers": "^8.11.2",
"echarts": "^5.5.0", "@react-google-maps/api": "^2.20.7",
"echarts-for-react": "^3.0.2", "@types/zxcvbn": "^4.4.5",
"formik": "^2.4.6", "@vis.gl/react-google-maps": "^1.5.4",
"js-cookie": "^3.0.5", "axios": "^1.10.0",
"jwt-decode": "^4.0.0", "date-fns": "^4.1.0",
"lucide-react": "^0.525.0", "dayjs": "^1.11.10",
"material-ui-popup-state": "^5.1.0", "echarts": "^5.5.0",
"react": "^18.2.0", "echarts-for-react": "^3.0.2",
"react-dom": "^18.2.0", "eslint-config-prettier": "^10.1.8",
"react-router-dom": "^6.22.3", "formik": "^2.4.6",
"simplebar-react": "^3.2.5" "js-cookie": "^3.0.5",
}, "jwt-decode": "^4.0.0",
"devDependencies": { "lucide-react": "^0.525.0",
"@iconify/react": "^4.1.1", "material-ui-popup-state": "^5.1.0",
"@types/react": "^18.2.66", "react": "^18.2.0",
"@types/react-dom": "^18.2.22", "react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^7.2.0", "react-router-dom": "^6.22.3",
"@typescript-eslint/parser": "^7.2.0", "simplebar-react": "^3.2.5",
"@vitejs/plugin-react": "^4.2.1", "zxcvbn": "^4.4.2"
"eslint": "^8.57.0", },
"eslint-plugin-prettier": "^5.5.3", "devDependencies": {
"eslint-plugin-react-hooks": "^4.6.0", "@iconify/react": "^4.1.1",
"eslint-plugin-react-refresh": "^0.4.6", "@types/react": "^18.2.66",
"typescript": "^5.2.2", "@types/react-dom": "^18.2.22",
"vite": "^5.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"vite-tsconfig-paths": "^4.3.2" "@typescript-eslint/parser": "^7.2.0",
} "@vitejs/plugin-react": "^4.2.1",
} "eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

@@ -1,14 +1,14 @@
import axios from 'axios'; import axios from 'axios';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
const baseURL = 'http://127.0.0.1:8010/api/'; const baseURL = import.meta.env.VITE_API_URL;
//const baseURL = 'https://backend.ditchtheagent.com/api/'; console.log(baseURL);
export const axiosRealEstateApi = axios.create({ export const axiosRealEstateApi = axios.create({
baseURL: 'https://api.realestateapi.com/v2/', baseURL: 'https://api.realestateapi.com/v2/',
headers: { headers: {
'Content-Type': 'application/json', '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', 'X-User-Id': 'UniqueUserIdentifier',
}, },
}); });

View File

@@ -3,7 +3,6 @@ import React, { ReactNode } from 'react';
import { Grid } from '@mui/material'; import { Grid } from '@mui/material';
import { GenericCategory } from 'types'; import { GenericCategory } from 'types';
interface CategoryGridTemplateProps<TCategory extends GenericCategory> { interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
categories: TCategory[]; categories: TCategory[];
onSelectCategory: (categoryId: string) => void; onSelectCategory: (categoryId: string) => void;
@@ -18,7 +17,7 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
{categories.map((category) => ( {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)} {renderCategoryCard(category, onSelectCategory)}
</Grid> </Grid>
))} ))}
@@ -26,4 +25,4 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
); );
} }
export default CategoryGridTemplate; export default CategoryGridTemplate;

View File

@@ -1,14 +1,32 @@
// src/templates/ItemListDetailTemplate.tsx // src/templates/ItemListDetailTemplate.tsx
import React, { useState, useEffect, ReactNode } from 'react'; 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 { GenericCategory, GenericItem } from 'types';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 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; category: TCategory;
items: TItem[]; items: TItem[];
onBack: () => void; 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; renderItemDetail: (item: TItem) => ReactNode;
} }
@@ -22,36 +40,35 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null); const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
// Default to the first item in the list // Default to the first item in the list
let temp = null let temp = null;
useEffect(() => { useEffect(() => {
if (items.length > 0) { if (items.length > 0) {
temp = items[0].id temp = items[0].id;
setSelectedItemId(items[0].id); setSelectedItemId(items[0].id);
} else { } else {
setSelectedItemId(null); setSelectedItemId(null);
} }
}, [items]); }, [items]);
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null; const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
console.log(selectedItemId, selectedItem) console.log(selectedItemId, selectedItem);
const handleItemSelect = (itemId: string) => { const handleItemSelect = (itemId: string) => {
setSelectedItemId(itemId); setSelectedItemId(itemId);
}; };
return ( return (
<Grid container spacing={3}> <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' }}> <Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
<Stack direction="row"> <Stack direction="row">
<IconButton size='small' color="inherit" onClick={onBack} sx={{mr:1}}> <IconButton size="small" color="inherit" onClick={onBack} sx={{ mr: 1 }}>
<ArrowBackIcon /> <ArrowBackIcon />
</IconButton> </IconButton>
<Typography variant="h6" gutterBottom>{category.name} List</Typography> <Typography variant="h6" gutterBottom>
{category.name} List
</Typography>
</Stack> </Stack>
<List> <List>
{items.map((item) => ( {items.map((item) => (
@@ -62,12 +79,23 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
</List> </List>
</Paper> </Paper>
</Grid> </Grid>
<Grid item xs={12} md={8}> <Grid size={{ xs: 12, md: 8 }}>
{selectedItem ? ( {selectedItem ? (
renderItemDetail(selectedItem) renderItemDetail(selectedItem)
) : ( ) : (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Paper
<Typography variant="h6" color="text.secondary">Select an item to view details</Typography> 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> </Paper>
)} )}
</Grid> </Grid>
@@ -75,4 +103,4 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
); );
} }
export default ItemListDetailTemplate; export default ItemListDetailTemplate;

View 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;

View File

@@ -1,25 +1,36 @@
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material'; import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
import { ReactElement} from 'react'; import { ReactElement } from 'react';
const LoadingSkeleton = (): ReactElement => { const LoadingSkeleton = (): ReactElement => {
return ( return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}> <Container
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}> maxWidth="lg"
<Grid container sx={{ height: '100%' }}> sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
<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' }}> <Paper
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}> elevation={3}
<Skeleton variant="text" sx={{ fontSize: '1rem' }} /> sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
</Typography> >
</Box> <Grid container sx={{ height: '100%' }}>
</Grid> <Grid
</Grid> size={{ xs: 12, sm: 6, md: 4 }}
</Paper> 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>
);
};
</Container> export default LoadingSkeleton;
)
}
export default LoadingSkeleton;

View File

@@ -30,7 +30,7 @@ const VendorBidsPage: React.FC = () => {
</Typography> </Typography>
<Grid container spacing={3} sx={{ mt: 3 }}> <Grid container spacing={3} sx={{ mt: 3 }}>
{bids.map((bid) => ( {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} /> <VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
</Grid> </Grid>
))} ))}

View File

@@ -80,7 +80,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
)} )}
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Active Cases Card */} {/* Active Cases Card */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -113,7 +113,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
</Card> </Card>
</Grid> </Grid>
{/* Upcoming Deadlines Card */} {/* Upcoming Deadlines Card */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -141,7 +141,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
</Card> </Card>
</Grid> </Grid>
{/* Documents to Review Card */} {/* Documents to Review Card */}
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>

View File

@@ -1,11 +1,12 @@
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { ReactElement, useContext, useEffect, useState } from 'react'; import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, UserAPI } from 'types'; import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi'; import { axiosInstance } from '../../../../../axiosApi';
import Grid from '@mui/material/Unstable_Grid2'; //==import Grid from '@mui/material/Unstable_Grid2';
import { import {
Alert, Alert,
Button, Button,
Grid,
Card, Card,
CardActionArea, CardActionArea,
CardContent, CardContent,
@@ -26,6 +27,7 @@ import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { GridColDef } from '@mui/x-data-grid'; import { GridColDef } from '@mui/x-data-grid';
import PropertyDetailCard from '../Property/PropertyDetailCard'; import PropertyDetailCard from '../Property/PropertyDetailCard';
import { DashboardProps } from 'pages/home/Dashboard'; import { DashboardProps } from 'pages/home/Dashboard';
import SavedPropertiesTable from './SavedPropertiesTable';
interface Row { interface Row {
id: number; id: number;
@@ -35,6 +37,9 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const [properties, setProperties] = useState<PropertiesAPI[]>([]); const [properties, setProperties] = useState<PropertiesAPI[]>([]);
const [numBids, setNumBids] = useState<Number>(0); const [numBids, setNumBids] = useState<Number>(0);
const [numOffers, setNumOffers] = 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(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -69,16 +74,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
console.log(error); console.log(error);
} }
}; };
const fetchSavedProperties = async () => {
try {
let expandedSavedProperties: PropertiesAPI[] = [];
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
await axiosInstance.get('/saved-properties/');
const requests = data.map((item) =>
axiosInstance.get(`/properties/${item.property}/?search=1`),
);
const responses = await Promise.all(requests);
expandedSavedProperties = responses.map((response) => response.data);
console.log(expandedSavedProperties);
setSavedProperties(expandedSavedProperties);
} catch (error) {
console.log(error);
}
};
const fetchDocuments = async () => {
try {
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
console.log('documents', data);
setDocuments(data);
} catch (error) {
console.log(error);
}
};
fetchProperties(); fetchProperties();
fetchOffers(); fetchOffers();
fetchBids(); fetchBids();
fetchSavedProperties();
fetchDocuments();
}, []); }, []);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => { const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
console.log('handle save. IMPLEMENT ME'); 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) => { const handleDeleteProperty = async (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME'); 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[] = [ const documentColumns: GridColDef[] = [
@@ -122,6 +183,8 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const numSaves = properties.reduce((accum, currProperty) => { const numSaves = properties.reduce((accum, currProperty) => {
return accum + currProperty.saves; return accum + currProperty.saves;
}, 0); }, 0);
const savedPropertiesCardLength: number = savedProperties.length === 0 ? 6 : 12;
const documentsCardLength: number = documents.length === 0 ? 6 : 12;
return ( return (
<Grid <Grid
@@ -201,11 +264,38 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid xs={12}>
<Divider sx={{ my: 2 }} /> {account.tier === 'basic' && (
</Grid> <Grid xs={12} md={4}>
<Card>
<Stack direction="column">
<Typography variant="h4">Upgrade your account</Typography>
<Typography variant="caption">
Unlock premium features to get more features and sell faster
</Typography>
</Stack>
<CardActionArea>
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
</Grid>
)}
{/* Properties */} {/* 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) => ( {properties.map((item) => (
<Grid xs={12} key={item.id}> <Grid xs={12} key={item.id}>
<PropertyDetailCard <PropertyDetailCard
@@ -221,80 +311,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
<Grid xs={12}> <Grid xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
</Grid> </Grid>
<Grid xs={12} md={6}>
<Grid xs={12} md={documentsCardLength}>
<Card sx={{ display: 'flex' }}> <Card sx={{ display: 'flex' }}>
<Stack direction="column"> <Stack direction="column">
<Typography variant="h4">Documents Requiring Attention</Typography> <Typography variant="h4">Documents Requiring Attention</Typography>
<Typography variant="caption">something</Typography>
</Stack> </Stack>
{documents.length === 0 ? (
<CardContent sx={{ flexGrow: 1 }}> <Typography variant="caption">
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} /> There are no documents that require your attention at this point
</CardContent> </Typography>
) : (
<CardContent sx={{ flexGrow: 1 }}>
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
</CardContent>
)}
</Card> </Card>
</Grid> </Grid>
<Grid xs={12} md={6}> <Grid xs={12} md={savedPropertiesCardLength}>
<Card> <Card>
<Stack direction="column"> <Stack direction="column">
<Typography variant="h4">Video Progress</Typography> <Stack direction="column">
<Typography variant="caption"> <Typography variant="h4">Saved Properties</Typography>
Complete our FSBO training to maximize your sale potential <Typography variant="caption">Keep track of the properties you have saved</Typography>
</Typography> </Stack>
</Stack>
<CardContent> <CardContent>
<EducationInfoCards /> <SavedPropertiesTable savedProperties={savedProperties} />
</CardContent> </CardContent>
</Stack>
</Card> </Card>
</Grid> </Grid>
<Grid xs={12} md={4}> <Grid xs={12} md={12}>
<Card> {account.tier === 'premium' ? (
<Stack direction="column"> <Card>
<Typography variant="h4">Upgrade your account</Typography> <Stack direction="column">
<Typography variant="caption"> <Stack direction="column">
Unlock premium features to get more features and sell faster <Typography variant="h4">Video Progress</Typography>
</Typography> <Typography variant="caption">
</Stack> Complete our FSBO training to maximize your sale potential
</Typography>
</Stack>
<CardActionArea> <CardContent>
<Button variant="contained" component="label"> <EducationInfoCards />
Upgrade </CardContent>
</Button> </Stack>
<Button>Learn More</Button> </Card>
</CardActionArea> ) : (
</Card> <Card>
</Grid> <Stack direction="column">
{/* <Grid xs={12} md={4}> <Typography variant="h4">Video Progress</Typography>
<NotificationInfoCard /> <Typography variant="caption">
</Grid> Upgrade to get access to FSBO educational videos
</Typography>
</Stack>
<Grid xs={12} md={8}> <CardActionArea>
<EducationInfoCards /> <Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
)}
</Grid> </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> </Grid>
); );
}; };

View File

@@ -61,7 +61,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
</Typography> </Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Listings Summary Card */} {/* Listings Summary Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -83,7 +83,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
</Grid> </Grid>
{/* New Offers Card */} {/* New Offers Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -114,7 +114,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
</Grid> </Grid>
{/* Upcoming Showings Card */} {/* Upcoming Showings Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -148,7 +148,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
</Grid> </Grid>
{/* Example of other cards */} {/* Example of other cards */}
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h5" gutterBottom> <Typography variant="h5" gutterBottom>

View File

@@ -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;

View File

@@ -165,7 +165,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
)} )}
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Views Card */} {/* Views Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -189,7 +189,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
</Grid> </Grid>
{/* Bids Card */} {/* Bids Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -240,7 +240,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
</Grid> </Grid>
{/* Conversations Card */} {/* Conversations Card */}
<Grid item xs={12} sm={6} md={4}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card> <Card>
<CardContent> <CardContent>
<Box display="flex" alignItems="center" mb={2}> <Box display="flex" alignItems="center" mb={2}>
@@ -278,10 +278,10 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid>*/} </Grid>*/}
<Grid xs={12}> <Grid size={{ xs: 12 }}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
</Grid> </Grid>
<Grid item xs={12} md={12}> <Grid size={{ xs: 12, md: 12 }}>
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} /> <VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -13,7 +13,7 @@ const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategor
return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
{categories.map((category) => ( {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} /> <CategoryCard category={category} onSelectCategory={onSelectCategory} />
</Grid> </Grid>
))} ))}
@@ -21,4 +21,4 @@ const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategor
); );
}; };
export default CategoryGrid; export default CategoryGrid;

View File

@@ -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 { axiosInstance } from '../../../../../axiosApi';
import { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material'; import { VideoProgressAPI } from 'types';
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { useNavigate } from 'react-router-dom'
import { renderProgress } from '@mui/x-data-grid-generator';
import { GridColDef } from '@mui/x-data-grid';
type EducationInfoProps = { interface CategoryProgress {
title: string; categoryName: string;
totalProgress: number;
videoCount: number;
averageProgress: number;
} }
interface Row {
id: number;
task: string;
progress: number; // Value from 0 to 100 for the progress bar
}
export const EducationInfoCards = () => {
return(
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
<EducationInfo title={'Education'} />
interface EducationInfoCardProps {
category: string;
progress: number;
totalVideos: number;
completedVideos: number;
}
const EducationInfoCard = ({
category,
progress,
totalVideos,
completedVideos,
}: EducationInfoCardCardProps): ReactElement => {
return (
<Card sx={{ boxShadow: 4, width: '100%' }}>
<CardContent>
<Stack spacing={1}>
<Typography variant="h6" component="h2">
{category}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 8, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary">
{completedVideos} of {totalVideos} videos complete
</Typography>
</Stack> </Stack>
) </CardContent>
}
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>
</Card> </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>
);
}
};

View File

@@ -1,25 +1,25 @@
// src/components/VideoApp/VideoCategoryCard.tsx // src/components/VideoApp/VideoCategoryCard.tsx
import React from 'react'; 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'; import { VideoCategory } from 'types';
interface VideoCategoryCardProps { interface VideoCategoryCardProps {
category: VideoCategory; category: VideoCategory;
onSelectCategory: (categoryId: string) => void; // Now uses categoryId onSelectCategory: (categoryId: string) => void; // Now uses categoryId
} }
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => { const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
console.log(category)
return ( return (
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}> <Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia <CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
component="img"
height="140"
image={category.imageUrl}
alt={category.name}
/>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="div"> <Typography gutterBottom variant="h5" component="div">
{category.name} {category.name}
@@ -28,9 +28,20 @@ const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelec
{category.description} {category.description}
</Typography> </Typography>
<Box sx={{ width: '100%', mt: 2 }}> <Box sx={{ width: '100%', mt: 2 }}>
<Typography variant="body2" color="text.secondary">{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography> <Typography
<LinearProgress variant="determinate" value={category.categoryProgress} sx={{ height: 8, borderRadius: 5, mt: 1 }} /> variant="body2"
<Typography variant="caption" display="block" align="right">{`${category.categoryProgress.toFixed(0)}%`}</Typography> 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> </Box>
</CardContent> </CardContent>
<Box sx={{ p: 2, pt: 0 }}> <Box sx={{ p: 2, pt: 0 }}>
@@ -42,4 +53,4 @@ const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelec
); );
}; };
export default VideoCategoryCard; export default VideoCategoryCard;

View File

@@ -2,7 +2,7 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material'; import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
import { AccountContext } from 'contexts/AccountContext'; import { AccountContext } from 'contexts/AccountContext';
import {axiosInstance} from '../../../../../axiosApi'; import { axiosInstance } from '../../../../../axiosApi';
import { VideoItem, VideoProgressAPI } from 'types'; import { VideoItem, VideoProgressAPI } from 'types';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause'; import PauseIcon from '@mui/icons-material/Pause';
@@ -20,22 +20,34 @@ interface VideoProgress {
progress: number; progress: number;
} }
interface VideoPlayerProps { interface VideoPlayerProps {
video: VideoItem; video: VideoItem;
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
} }
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => { const VideoPlayer: React.FC<VideoPlayerProps> = ({ video, updateVideoItem }) => {
const {account, accountLoading} = useContext(AccountContext); const { account, accountLoading } = useContext(AccountContext);
if (!video || accountLoading) { if (!video || accountLoading) {
return ( return (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Paper
<Typography variant="h6" color="text.secondary">No video selected</Typography> elevation={2}
sx={{
p: 3,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="h6" color="text.secondary">
No video selected
</Typography>
</Paper> </Paper>
); );
} }
//
//
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -47,37 +59,41 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Function to save progress to the backend // Function to save progress to the backend
const saveProgress = useCallback(async (time: number, completed: boolean = false) => { const saveProgress = useCallback(
if (!videoRef.current) return; 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 { try {
// First, try to fetch existing progress // First, try to fetch existing progress
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`); const response = await axiosInstance.get(
if (response.data.length > 0) { `/videos/progress/?user=${account?.id}&video=${video.id}`,
// If progress exists, update it );
const existingProgress = response.data[0]; if (response.data.length > 0) {
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData); // If progress exists, update it
} else { const existingProgress = response.data[0];
// If no progress, create a new one await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
await axiosInstance.post('/videos/progress/', 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!'); [video.id, account?.id],
} else { );
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
}
setSnackbarOpen(true);
} catch (err) {
console.error('Failed to save video progress:', err);
setError('Failed to save video progress.');
}
}, [video.id, account?.id]);
// Fetch initial progress when video changes or component mounts // Fetch initial progress when video changes or component mounts
useEffect(() => { useEffect(() => {
@@ -85,14 +101,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { 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) { );
if (data) {
const progress: VideoProgress = { const progress: VideoProgress = {
current_time: data.progress, current_time: data.progress,
progress: data.progress, progress: data.progress,
} };
setCurrentTime(progress?.current_time || 0); setCurrentTime(progress?.current_time || 0);
if (videoRef.current) { if (videoRef.current) {
@@ -128,7 +145,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
}; };
}, [video.id, account?.id, saveProgress]); }, [video.id, account?.id, saveProgress]);
// Video event handlers // Video event handlers
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
if (videoRef.current) { if (videoRef.current) {
@@ -148,7 +164,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const handlePlay = () => { const handlePlay = () => {
setIsPlaying(true); setIsPlaying(true);
if (videoRef.current) { 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 // Attempt to play if it was playing before, or if it's the first load
if (videoRef.current && currentTime > 0) { if (videoRef.current && currentTime > 0) {
videoRef.current.currentTime = currentTime; 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); setIsPlaying(true);
} }
}; };
@@ -195,7 +211,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
if (!videoRef.current) return; if (!videoRef.current) return;
if (!document.fullscreenElement) { 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})`); alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
}); });
} else { } else {
@@ -227,18 +243,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
setSnackbarOpen(false); setSnackbarOpen(false);
}; };
return ( return (
<Paper elevation={3} sx={{ p: 2 }}> <Paper elevation={3} sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>{video.name}</Typography> <Typography variant="h6" gutterBottom>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>{video.description}</Typography> {video.name}
<Box sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}> </Typography>
<video <Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
ref={videoRef} {video.description}
src={video.videoUrl} </Typography>
controls={false} <Box
onTimeUpdate={handleTimeUpdate} sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}
onEnded={handleEnded} >
<video
ref={videoRef}
src={video.videoUrl}
controls={false}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
onLoadedData={handleLoadedData} onLoadedData={handleLoadedData}
onError={handleVideoError} onError={handleVideoError}
style={{ style={{
@@ -250,13 +271,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
backgroundColor: '#000', backgroundColor: '#000',
borderRadius: 2, 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>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
@@ -276,15 +293,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
</Box> </Box>
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}> <Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
<IconButton onClick={toggleFullScreen} color="primary" size="large"> <IconButton onClick={toggleFullScreen} color="primary" size="large">
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />} {isFullScreen ? (
<ExitFullscreenIcon fontSize="inherit" />
) : (
<FullscreenIcon fontSize="inherit" />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
<Typography variant="body2" sx={{ mt: 2 }}> <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> </Typography>
</Paper> </Paper>
); );
}; };
export default VideoPlayer; export default VideoPlayer;

View File

@@ -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;

View File

@@ -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;

View File

@@ -101,7 +101,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
event: React.SyntheticEvent, event: React.SyntheticEvent,
value: string, value: string,
) => { ) => {
const test: boolean = true; const test: boolean = !import.meta.env.USE_LIVE_DATA;
let data: AutocompleteDataResponseAPI[] = []; let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) { if (value.length > 2) {
if (test) { if (test) {
@@ -285,7 +285,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
<DialogTitle>Add New Property</DialogTitle> <DialogTitle>Add New Property</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Autocomplete <Autocomplete
options={autocompleteOptions} options={autocompleteOptions}
getOptionLabel={(option) => option.description} getOptionLabel={(option) => option.description}
@@ -319,7 +319,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
)} )}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="City" label="City"
@@ -331,7 +331,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.city} helperText={formErrors.city}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="State" label="State"
@@ -343,7 +343,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.state} helperText={formErrors.state}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Zip Code" label="Zip Code"
@@ -355,7 +355,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.zip_code} helperText={formErrors.zip_code}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Description" label="Description"
@@ -366,7 +366,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="Square Footage" label="Square Footage"
@@ -378,7 +378,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.sq_ft} helperText={formErrors.sq_ft}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="# Bedrooms" label="# Bedrooms"
@@ -390,7 +390,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.num_bedrooms} helperText={formErrors.num_bedrooms}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="# Bathrooms" label="# Bathrooms"
@@ -402,7 +402,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
helperText={formErrors.num_bathrooms} helperText={formErrors.num_bathrooms}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Features (comma-separated)" label="Features (comma-separated)"
@@ -416,7 +416,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
} }
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Market Value" label="Market Value"
@@ -425,7 +425,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Amount" label="Loan Amount"
@@ -434,7 +434,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Term (years)" label="Loan Term (years)"
@@ -444,7 +444,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Start Date" label="Loan Start Date"
@@ -455,7 +455,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Button variant="contained" component="label"> <Button variant="contained" component="label">
Upload Pictures Upload Pictures
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} /> <input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
@@ -472,7 +472,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
</Box> </Box>
</Grid> </Grid>
{newProperty.latitude && newProperty.longitude && ( {newProperty.latitude && newProperty.longitude && (
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<MapComponent <MapComponent
lat={newProperty.latitude} lat={newProperty.latitude}
lng={newProperty.longitude} lng={newProperty.longitude}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, ReactElement } from 'react'; import React, { useState, useEffect, ReactElement } from 'react';
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material'; 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 { ProfileProps } from 'pages/Profile/Profile';
import DashboardLoading from '../Dashboard/DashboardLoading'; import DashboardLoading from '../Dashboard/DashboardLoading';
import DashboardErrorPage from '../Dashboard/DashboardErrorPage'; import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
@@ -111,6 +112,10 @@ const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
onUpgrade={handleUpgradeSubscription} onUpgrade={handleUpgradeSubscription}
onSave={handleSaveAttorneyProfile} onSave={handleSaveAttorneyProfile}
/> />
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
</Container> </Container>
); );
}; };

View File

@@ -115,7 +115,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
event: React.SyntheticEvent, event: React.SyntheticEvent,
value: string, value: string,
) => { ) => {
const test: boolean = true; const test: boolean = !import.meta.env.USE_LIVE_DATA;
let data: AutocompleteDataResponseAPI[] = []; let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) { if (value.length > 2) {
if (test) { if (test) {
@@ -243,7 +243,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
)} )}
</Box> </Box>
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="First Name" label="First Name"
@@ -253,7 +253,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Last Name" label="Last Name"
@@ -263,7 +263,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Email Address" label="Email Address"
@@ -274,7 +274,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Firm Name" label="Firm Name"
@@ -287,7 +287,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.firm_name} helperText={formErrors.firm_name}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Phone Number" label="Phone Number"
@@ -300,7 +300,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.phone_number} helperText={formErrors.phone_number}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Autocomplete <Autocomplete
options={autocompleteOptions} options={autocompleteOptions}
getOptionLabel={(option) => option.description} getOptionLabel={(option) => option.description}
@@ -345,7 +345,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.address} helperText={formErrors.address}
/>*/} />*/}
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="City" label="City"
@@ -358,7 +358,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.city} helperText={formErrors.city}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="State" label="State"
@@ -371,7 +371,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.state} helperText={formErrors.state}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="Zip Code" label="Zip Code"
@@ -399,7 +399,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
))} ))}
</Box> </Box>
</Grid>*/} </Grid>*/}
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Years of Experience" label="Years of Experience"
@@ -412,7 +412,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
helperText={formErrors.years_experience} helperText={formErrors.years_experience}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Licensed States (comma-separated)" label="Licensed States (comma-separated)"
@@ -427,7 +427,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
))} ))}
</Box> </Box>
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Website URL" label="Website URL"
@@ -437,7 +437,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Biography" label="Biography"
@@ -449,7 +449,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Button <Button
variant="contained" variant="contained"
component="label" component="label"
@@ -460,7 +460,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} /> <input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
</Button> </Button>
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'} Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography> </Typography>
@@ -477,7 +477,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
)} )}
</Grid> </Grid>
{editedAttorney.latitude && editedAttorney.longitude && ( {editedAttorney.latitude && editedAttorney.longitude && (
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
Firm Location on Map: Firm Location on Map:
</Typography> </Typography>

View File

@@ -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;

View File

@@ -1,28 +1,97 @@
import React, { useState } from 'react'; 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 { 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'; listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
listingPrice: number;
existingOffer?: {
document_id: string;
};
} }
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
onOfferSubmit, onOfferSubmit,
listingStatus, listingStatus,
listingPrice,
existingOffer,
}) => { }) => {
const [offerAmount, setOfferAmount] = useState<string>(''); const [offerAmount, setOfferAmount] = useState<string>('');
const [closingDuration, setClosingDuration] = useState<string>('');
const [contingencies, setContingencies] = useState<string>('None');
const [submitted, setSubmitted] = useState(false); 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') { if (listingStatus === 'active') {
const handleSubmit = () => { const handleSubmit = async () => {
const amount = parseFloat(offerAmount); const amount = parseFloat(offerAmount);
if (amount > 0) { const closing_days = parseFloat(closingDuration);
onOfferSubmit(amount); if (amount > 0 && closing_days) {
setSubmitted(true); try {
setTimeout(() => setSubmitted(false), 5000); 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 ( return (
<Card> <Card>
<CardContent> <CardContent>
@@ -36,8 +105,38 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
value={offerAmount} value={offerAmount}
onChange={(e) => setOfferAmount(e.target.value)} onChange={(e) => setOfferAmount(e.target.value)}
sx={{ mb: 2 }} 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 Submit Offer
</Button> </Button>
{submitted && ( {submitted && (
@@ -45,6 +144,11 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
Your offer of ${offerAmount} has been submitted! Your offer of ${offerAmount} has been submitted!
</Alert> </Alert>
)} )}
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,58 +1,66 @@
import React from 'react'; 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 { OpenHouseAPI } from 'types';
import { format } from 'date-fns';
interface OpenHouseCardProps { interface OpenHouseCardProps {
openHouses: OpenHouseAPI[] | undefined; openHouses: OpenHouseAPI[] | undefined;
} }
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => { const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
if(openHouses){ if (openHouses) {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Open House Information Open House Information
</Typography> </Typography>
{openHouses.length > 0 ? ( {openHouses.length > 0 ? (
<List dense> <List dense>
{openHouses.map((openHouse, index) => ( {openHouses.map((openHouse, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={`${openHouse.date} at ${openHouse.time}`} primary={`${format(new Date(openHouse.listed_date), 'MMM d, yyyy')} at ${format(
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`} new Date(`1970-01-01T${openHouse.start_time}`),
/> 'h a',
</ListItem> )} - ${format(new Date(`1970-01-01T${openHouse.end_time}`), 'h a')}`}
{index < openHouses.length - 1 && <Divider component="li" />} />
</React.Fragment> </ListItem>
))} {index < openHouses.length - 1 && <Divider component="li" />}
</List> </React.Fragment>
) : ( ))}
<Typography variant="body2" color="text.secondary"> </List>
No upcoming open houses scheduled. ) : (
</Typography> <Typography variant="body2" color="text.secondary">
)} No upcoming open houses scheduled.
</CardContent> </Typography>
</Card> )}
</CardContent>
</Card>
); );
} else {
}else{ return (
return ( <Card>
<Card> <CardContent>
<CardContent> <Typography variant="h6" gutterBottom>
<Typography variant="h6" gutterBottom> Open House Information
Open House Information </Typography>
</Typography> <Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary"> No upcoming open houses scheduled.
No upcoming open houses scheduled. </Typography>
</Typography> </CardContent>
</CardContent> </Card>
</Card>
); );
} }
}; };
export default OpenHouseCard; export default OpenHouseCard;

View File

@@ -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;

View File

@@ -20,9 +20,15 @@ interface ProfileCardProps {
user: UserAPI; user: UserAPI;
onUpgrade: () => void; onUpgrade: () => void;
onSave: (updatedUser: UserAPI) => 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 [isEditing, setIsEditing] = useState(false);
const [editedUser, setEditedUser] = useState<UserAPI>(user); const [editedUser, setEditedUser] = useState<UserAPI>(user);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({}); const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
@@ -39,18 +45,23 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
console.log(editedUser); console.log(editedUser);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target; if (isEditing) {
setEditedUser((prev) => ({ const { name, value, type, checked } = e.target;
...prev, setEditedUser((prev) => ({
[name]: type === 'checkbox' ? checked : value, ...prev,
})); [name]: type === 'checkbox' ? checked : value,
// Clear error for the field being edited }));
if (formErrors[name]) { // Clear error for the field being edited
setFormErrors((prev) => { if (formErrors[name]) {
const newErrors = { ...prev }; setFormErrors((prev) => {
delete newErrors[name]; const newErrors = { ...prev };
return newErrors; 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> </Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="First Name" label="First Name"
name="first_name" name="first_name"
value={editedUser.first_name} value={editedUser.first_name}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.first_name} error={!!formErrors.first_name}
helperText={formErrors.first_name} helperText={formErrors.first_name}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Last Name" label="Last Name"
name="last_name" name="last_name"
value={editedUser.last_name} value={editedUser.last_name}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.last_name} error={!!formErrors.last_name}
helperText={formErrors.last_name} helperText={formErrors.last_name}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Email Address" label="Email Address"
@@ -133,12 +142,11 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
type="email" type="email"
value={editedUser.email} value={editedUser.email}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.email} error={!!formErrors.email}
helperText={formErrors.email} helperText={formErrors.email}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'} Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography> </Typography>
@@ -148,7 +156,7 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
</Button> </Button>
)} )}
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Typography variant="subtitle1">Notification Settings:</Typography> <Typography variant="subtitle1">Notification Settings:</Typography>
{/* Example Checkboxes - You'd manage these with state too */} {/* Example Checkboxes - You'd manage these with state too */}
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>

View File

@@ -1,112 +1,104 @@
import React from 'react'; import React from 'react';
import { import {
Card, Card,
CardContent, CardContent,
Typography, Typography,
Grid, Grid,
Box, Box,
ImageList, ImageList,
ImageListItem, ImageListItem,
ImageListItemBar, ImageListItemBar,
Button, Button,
} from '@mui/material'; } from '@mui/material';
import MapComponent from '../../../../base/MapComponent'; import MapComponent from '../../../../base/MapComponent';
import { PropertiesAPI } from 'types'; import { PropertiesAPI } from 'types';
interface PropertyCardProps { interface PropertyCardProps {
property: PropertiesAPI; property: PropertiesAPI;
} }
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => { const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
// Dummy latitude and longitude for demonstration // Dummy latitude and longitude for demonstration
// In a real app, you'd geocode the address to get these. // In a real app, you'd geocode the address to get these.
const demoLat = 34.0522; const demoLat = 34.0522;
const demoLng = -118.2437; // Example: Los Angeles coordinates const demoLng = -118.2437; // Example: Los Angeles coordinates
console.log(property) console.log(property);
return ( return (
<Card sx={{ mt: 3, p: 2 }}> <Card sx={{ mt: 3, p: 2 }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
{property.address}, {property.city}, {property.state} {property.zip_code} {property.address}, {property.city}, {property.state} {property.zip_code}
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
{property.pictures && property.pictures.length > 0 && ( {property.pictures && property.pictures.length > 0 && (
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}> <ImageList
{property.pictures.map((item, index) => ( cols={property.pictures.length > 1 ? 2 : 1}
<ImageListItem key={index}> rowHeight={164}
<img sx={{ maxWidth: 500 }}
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x, >
{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`} ${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
src={`${item}?w=164&h=164&fit=crop&auto=format`} src={`${item}?w=164&h=164&fit=crop&auto=format`}
alt={`Property image ${index + 1}`} alt={`Property image ${index + 1}`}
loading="lazy" loading="lazy"
/> />
<ImageListItemBar <ImageListItemBar title={`Image ${index + 1}`} />
title={`Image ${index + 1}`} </ImageListItem>
/> ))}
</ImageListItem> </ImageList>
))} )}
</ImageList> <Typography variant="body1" sx={{ mt: 2 }}>
)} <strong>Description:</strong>
<Typography variant="body1" sx={{ mt: 2 }}> </Typography>
<strong>Description:</strong> {property.description ? (
</Typography> <Typography variant="body2" color="textSecondary">
{ property.description ? ( {property.description}
<Typography variant="body2" color="textSecondary"> </Typography>
{property.description} ) : (
</Typography> <Button variant="contained">Generate Description</Button>
)}
) : ( </Grid>
<Button variant='contained'> <Grid size={{ xs: 12, md: 6 }}>
Generate Description <Typography variant="body1">
</Button> <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>
</Grid> <Typography variant="body2">Bathrooms: {property.num_bathrooms || 'N/A'}</Typography>
<Grid item xs={12} md={6}> <Typography variant="body2">
<Typography variant="body1"> Features:{' '}
<strong>Stats:</strong> {property.features && property.features.length > 0
</Typography> ? property.features.join(', ')
<Typography variant="body2"> : 'None'}
Sq Ft: {property.sq_ft || 'N/A'} </Typography>
</Typography> <Typography variant="body2">Market Value: ${property.market_value || 'N/A'}</Typography>
<Typography variant="body2"> <Typography variant="body2">Loan Amount: ${property.loan_amount || 'N/A'}</Typography>
Bedrooms: {property.num_bedrooms || 'N/A'} <Typography variant="body2">
</Typography> Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
<Typography variant="body2"> </Typography>
Bathrooms: {property.num_bathrooms || 'N/A'} <Typography variant="body2">
</Typography> Loan Start Date: {property.loan_start_date || 'N/A'}
<Typography variant="body2"> </Typography>
Features: {property.features && property.features.length > 0 {property.latitude && property.longitude ? (
? property.features.join(', ') <MapComponent
: 'None'} lat={property.latitude}
</Typography> lng={property.longitude}
<Typography variant="body2"> address={property.address}
Market Value: ${property.market_value || 'N/A'} />
</Typography> ) : (
<Typography variant="body2"> <p>Error loading the map</p>
Loan Amount: ${property.loan_amount || 'N/A'} )}
</Typography> </Grid>
<Typography variant="body2"> </Grid>
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'} </CardContent>
</Typography> </Card>
<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; export default PropertyCard;

View File

@@ -1,7 +1,8 @@
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material'; import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
import ChangePasswordCard from './ChangePasswordCard';
import { ReactElement, useContext, useEffect, useState } from 'react'; 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 ProfileCard from './ProfileCard';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import PropertyCard from './PropertyCard.'; import PropertyCard from './PropertyCard.';
@@ -13,12 +14,18 @@ import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import DashboardLoading from '../Dashboard/DashboardLoading'; import DashboardLoading from '../Dashboard/DashboardLoading';
import { ProfileProps } from 'pages/Profile/Profile'; import { ProfileProps } from 'pages/Profile/Profile';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import AddOpenHouseDialog from './AddOpenHouseDialog';
import OpenHouseDialogContent from './OpenHouseDialogContext';
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => { const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
const [loadingData, setLoadingData] = useState<boolean>(true); const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate(); const navigate = useNavigate();
const [user, setUser] = useState<PropertyOwnerAPI | null>(null); 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(() => { useEffect(() => {
const fetchPropertyOwner = async () => { const fetchPropertyOwner = async () => {
try { try {
@@ -52,6 +59,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
console.log('setting the user to: ', data[0].owner); console.log('setting the user to: ', data[0].owner);
setUser(data[0].owner); setUser(data[0].owner);
} }
const { data: openHousesData }: AxiosResponse<OpenHouseAPI[]> = await axiosInstance.get(
'/properties/open-houses/',
);
setOpenHouses(openHousesData);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} finally { } finally {
@@ -104,6 +116,35 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
setOpenAddPropertyDialog(false); 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 = ( const handleAddProperty = (
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>, newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
) => { ) => {
@@ -134,29 +175,39 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
} }
}; };
const handleSaveProperty = (updatedProperty: PropertiesAPI) => { const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property try {
console.log('Saving property: IMPLEMENT ME', updatedProperty); 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) => { const handleDeleteProperty = async (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
try { try {
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete( await axiosInstance.delete(`/properties/${propertyId}/`);
`/properties/${propertyId}/`, setProperties((prev) => prev.filter((item) => item.id !== propertyId));
); setMessage({ type: 'success', text: 'Property has been removed' });
console.log(data); setTimeout(() => setMessage(null), 3000);
// remove the proprty from the list } catch (error) {
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId)); setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
// const indexToRemove = properties.findIndex(property => property.id === propertyId); setTimeout(() => setMessage(null), 3000);
// console.log(indexToRemove)
// if (indexToRemove !== -1) {
// const updatedProperties = properties.splice(indexToRemove, 1)
// console.log(updatedProperties)
// setProperties(updatedProperties);
// }
} catch {
console.log('error removing');
} }
}; };
@@ -182,8 +233,13 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
user={user.user} user={user.user}
onUpgrade={handleUpgradeSubscription} onUpgrade={handleUpgradeSubscription}
onSave={handleSaveProfile} onSave={handleSaveProfile}
setMessage={setMessage}
/> />
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
<Divider sx={{ my: 4 }} /> <Divider sx={{ my: 4 }} />
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> <Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
@@ -194,6 +250,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
Add Property Add Property
</Button> </Button>
</Box> </Box>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{properties.length === 0 ? ( {properties.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}> <Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary"> <Typography variant="body1" color="textSecondary">
@@ -203,7 +264,7 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
) : ( ) : (
<Grid container spacing={3}> <Grid container spacing={3}>
{properties.map((property) => ( {properties.map((property) => (
<Grid item xs={12} key={property.id}> <Grid size={{ xs: 12 }} key={property.id}>
{/* <PropertyCard property={property} /> */} {/* <PropertyCard property={property} /> */}
<PropertyDetailCard <PropertyDetailCard
property={property} property={property}
@@ -222,6 +283,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
onClose={handleCloseAddPropertyDialog} onClose={handleCloseAddPropertyDialog}
onAddProperty={handleAddProperty} 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> </Container>
); );
} }

View File

@@ -1,5 +1,6 @@
import { ReactElement, useContext, useEffect, useState } from 'react'; import { ReactElement, useContext, useEffect, useState } from 'react';
import { UserAPI, VendorAPI } from 'types'; import { UserAPI, VendorAPI } from 'types';
import ChangePasswordCard from './ChangePasswordCard';
import { import {
Container, Container,
Typography, Typography,
@@ -165,6 +166,10 @@ const VendorProfile = ({ account }: ProfileProps): ReactElement => {
onSave={handleSaveVendorProfile} onSave={handleSaveVendorProfile}
/> />
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
<Divider sx={{ my: 4 }} /> <Divider sx={{ my: 4 }} />
<ServicesCard <ServicesCard

View File

@@ -123,7 +123,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
event: React.SyntheticEvent, event: React.SyntheticEvent,
value: string, value: string,
) => { ) => {
const test: boolean = true; const test: boolean = !import.meta.env.USE_LIVE_DATA;
let data: AutocompleteDataResponseAPI[] = []; let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) { if (value.length > 2) {
if (test) { if (test) {
@@ -196,7 +196,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
)} )}
</Box> </Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="First Name" label="First Name"
@@ -206,7 +206,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Last Name" label="Last Name"
@@ -216,7 +216,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Email Address" label="Email Address"
@@ -227,7 +227,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Business Name" label="Business Name"
@@ -240,7 +240,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
helperText={formErrors.business_name} helperText={formErrors.business_name}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth disabled={!isEditing}> <FormControl fullWidth disabled={!isEditing}>
<InputLabel id="business-type-label">Business Type</InputLabel> <InputLabel id="business-type-label">Business Type</InputLabel>
<Select <Select
@@ -260,7 +260,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Phone Number" label="Phone Number"
@@ -273,7 +273,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
helperText={formErrors.phone_number} helperText={formErrors.phone_number}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Autocomplete <Autocomplete
options={autocompleteOptions} options={autocompleteOptions}
getOptionLabel={(option) => option.description} getOptionLabel={(option) => option.description}
@@ -307,7 +307,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
)} )}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="City" label="City"
@@ -320,7 +320,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
helperText={formErrors.city} helperText={formErrors.city}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="State" label="State"
@@ -333,7 +333,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
helperText={formErrors.state} helperText={formErrors.state}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="Zip Code" label="Zip Code"
@@ -346,7 +346,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
helperText={formErrors.zip_code} helperText={formErrors.zip_code}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Business Description" label="Business Description"
@@ -358,7 +358,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Website URL" label="Website URL"
@@ -368,7 +368,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth
label="Certifications (comma-separated)" label="Certifications (comma-separated)"
@@ -386,7 +386,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
disabled={!isEditing} disabled={!isEditing}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'} Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography> </Typography>

View File

@@ -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;

View File

@@ -159,10 +159,11 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
setIsGernerating(true); setIsGernerating(true);
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`); const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
console.log(response); setEditedProperty((prev) => ({
...prev,
description: response.data.description,
}));
setIsGernerating(false); setIsGernerating(false);
// TODO: toggle the update
}; };
return ( return (
@@ -195,10 +196,10 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Property Address & Basic Info */} {/* Property Address & Basic Info */}
<Grid item xs={12}> <Grid size={{ xs: 12 }}>
{isEditing ? ( {isEditing ? (
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={8}> <Grid size={{ xs: 8 }}>
<TextField <TextField
fullWidth fullWidth
label="Address" label="Address"
@@ -210,7 +211,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.address} helperText={formErrors.address}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="City" label="City"
@@ -222,7 +223,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.city} helperText={formErrors.city}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="State" label="State"
@@ -234,7 +235,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.state} helperText={formErrors.state}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={4}> <Grid size={{ xs: 12, sm: 4 }}>
<TextField <TextField
fullWidth fullWidth
label="Zip Code" label="Zip Code"
@@ -260,7 +261,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
</Grid> </Grid>
{/* Pictures */} {/* Pictures */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
Pictures: Pictures:
</Typography> </Typography>
@@ -306,8 +307,8 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
)} )}
</Grid> </Grid>
{/* Description & Stats */} {/* Description */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
Description: Description:
</Typography> </Typography>
@@ -333,13 +334,16 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
// {property.description || 'No description provided.'} // {property.description || 'No description provided.'}
// </Typography> // </Typography>
)} )}
</Grid>
<Typography variant="subtitle1" sx={{ mt: 2 }} gutterBottom> {/* Stats */}
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" gutterBottom>
Stats: Stats:
</Typography> </Typography>
{isEditing ? ( {isEditing ? (
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={6}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Sq Ft" label="Sq Ft"
@@ -351,7 +355,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.sq_ft} helperText={formErrors.sq_ft}
/> />
</Grid> </Grid>
<Grid item xs={6}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Bedrooms" label="Bedrooms"
@@ -363,7 +367,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.num_bedrooms} helperText={formErrors.num_bedrooms}
/> />
</Grid> </Grid>
<Grid item xs={6}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Bathrooms" label="Bathrooms"
@@ -375,7 +379,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
helperText={formErrors.num_bathrooms} helperText={formErrors.num_bathrooms}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Features (comma-separated)" label="Features (comma-separated)"
@@ -384,7 +388,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
onChange={handleFeaturesChange} onChange={handleFeaturesChange}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Market Value" label="Market Value"
@@ -393,7 +397,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
onChange={handleChange} onChange={handleChange}
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid size={{ xs: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Amount" label="Loan Amount"
@@ -402,7 +406,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
onChange={handleChange} onChange={handleChange}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Term (years)" label="Loan Term (years)"
@@ -412,7 +416,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
onChange={handleNumericChange} onChange={handleNumericChange}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Loan Start Date" label="Loan Start Date"
@@ -454,7 +458,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
</Grid> </Grid>
{/* Map */} {/* Map */}
<Grid item xs={12}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
Location on Map: Location on Map:
</Typography> </Typography>

View File

@@ -7,14 +7,23 @@ import { PropertiesAPI } from 'types';
interface PropertyListItemProps { interface PropertyListItemProps {
property: PropertiesAPI; property: PropertiesAPI;
onViewDetails: (propertyId: number) => void; // For navigation in search page 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 navigate = useNavigate();
const handleViewDetailsClick = () => { const handleViewDetailsClick = () => {
// Navigate to the full detail page for this property // 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_price = property.listed_price ? property.listed_price : property.market_value;
const value_text = property.listed_price ? 'Listed Price' : 'Market Value'; const value_text = property.listed_price ? 'Listed Price' : 'Market Value';

View File

@@ -1,107 +1,209 @@
import React, { useState } from 'react'; 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 SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
interface SearchFilters { interface SearchFilters {
address: string; address: string;
city: string; city: string;
state: string; state: string;
zipCode: string; zipCode: string;
minSqFt: number | ''; minSqFt: number | '';
maxSqFt: number | ''; maxSqFt: number | '';
minBedrooms: number | ''; minBedrooms: number | '';
maxBedrooms: number | ''; maxBedrooms: number | '';
minBathrooms: number | ''; minBathrooms: number | '';
maxBathrooms: number | ''; maxBathrooms: number | '';
} }
interface PropertySearchFiltersProps { interface PropertySearchFiltersProps {
onSearch: (filters: SearchFilters) => void; onSearch: (filters: SearchFilters) => void;
onClear: () => void; onClear: () => void;
} }
const initialFilters: SearchFilters = { const initialFilters: SearchFilters = {
address: '', address: '',
city: '', city: '',
state: '', state: '',
zipCode: '', zipCode: '',
minSqFt: '', minSqFt: '',
maxSqFt: '', maxSqFt: '',
minBedrooms: '', minBedrooms: '',
maxBedrooms: '', maxBedrooms: '',
minBathrooms: '', minBathrooms: '',
maxBathrooms: '', maxBathrooms: '',
}; };
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => { 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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value })); setFilters((prev) => ({ ...prev, [name]: value }));
}; };
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
const numValue = value === '' ? '' : parseFloat(value); const numValue = value === '' ? '' : parseFloat(value);
setFilters(prev => ({ ...prev, [name]: numValue })); setFilters((prev) => ({ ...prev, [name]: numValue }));
}; };
const handleSearchClick = () => { const handleSearchClick = () => {
onSearch(filters); onSearch(filters);
}; };
const handleClearClick = () => { const handleClearClick = () => {
setFilters(initialFilters); setFilters(initialFilters);
onClear(); onClear();
}; };
return ( const handleToggleExpand = () => {
<Paper sx={{ p: 3, mb: 3 }}> setExpanded(!expanded);
<Typography variant="h6" gutterBottom>Search Properties</Typography> };
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}> return (
<TextField fullWidth label="Address Keyword" name="address" value={filters.address} onChange={handleChange} /> <Paper sx={{ p: 3, mb: 3 }}>
</Grid> <Box
<Grid item xs={12} sm={6} md={3}> sx={{
<TextField fullWidth label="City" name="city" value={filters.city} onChange={handleChange} /> display: 'flex',
</Grid> justifyContent: 'space-between',
<Grid item xs={12} sm={6} md={3}> alignItems: 'center',
<TextField fullWidth label="State" name="state" value={filters.state} onChange={handleChange} /> cursor: 'pointer',
</Grid> }}
<Grid item xs={12} sm={6} md={3}> onClick={handleToggleExpand}
<TextField fullWidth label="Zip Code" name="zipCode" value={filters.zipCode} onChange={handleChange} /> >
</Grid> <Typography variant="h6" gutterBottom>
<Grid item xs={6} sm={3}> Property Filters
<TextField fullWidth label="Min Sq Ft" name="minSqFt" type="number" value={filters.minSqFt} onChange={handleNumericChange} /> </Typography>
</Grid> <IconButton
<Grid item xs={6} sm={3}> onClick={handleToggleExpand}
<TextField fullWidth label="Max Sq Ft" name="maxSqFt" type="number" value={filters.maxSqFt} onChange={handleNumericChange} /> aria-expanded={expanded}
</Grid> aria-label="toggle filters"
<Grid item xs={6} sm={3}> >
<TextField fullWidth label="Min Bedrooms" name="minBedrooms" type="number" value={filters.minBedrooms} onChange={handleNumericChange} /> <ExpandMoreIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} />
</Grid> </IconButton>
<Grid item xs={6} sm={3}> </Box>
<TextField fullWidth label="Max Bedrooms" name="maxBedrooms" type="number" value={filters.maxBedrooms} onChange={handleNumericChange} /> <Collapse in={expanded}>
</Grid> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={6} sm={3}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField fullWidth label="Min Bathrooms" name="minBathrooms" type="number" value={filters.minBathrooms} onChange={handleNumericChange} /> <TextField
</Grid> fullWidth
<Grid item xs={6} sm={3}> label="Address Keyword"
<TextField fullWidth label="Max Bathrooms" name="maxBathrooms" type="number" value={filters.maxBathrooms} onChange={handleNumericChange} /> name="address"
</Grid> value={filters.address}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> onChange={handleChange}
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}> />
Clear Filters </Grid>
</Button> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}> <TextField
Search fullWidth
</Button> label="City"
</Grid> name="city"
</Grid> value={filters.city}
</Paper> 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; export default PropertySearchFilters;

View File

@@ -13,13 +13,22 @@ import {
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteIcon from '@mui/icons-material/Favorite';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; 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 { interface PropertyStatusCardProps {
property: PropertiesAPI; property: PropertiesAPI;
isOwner: boolean; isOwner: boolean;
onStatusChange?: () => void; onStatusChange?: (string) => void;
onSavedPropertySave?: () => void; onSavedPropertySave?: () => void;
savedProperty: SavedPropertiesAPI | null;
sellerDisclosureExists: boolean;
} }
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
@@ -27,7 +36,17 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
isOwner, isOwner,
onStatusChange, onStatusChange,
onSavedPropertySave, 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']) => { const getStatusColor = (status: PropertiesAPI['property_status']) => {
switch (status) { switch (status) {
case 'active': case 'active':
@@ -44,7 +63,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
}; };
const timeSinceListed = (dateString: string) => { const timeSinceListed = (dateString: string) => {
const listedDate = new Date(dateString); const listedDate = new Date(dateString.split('T')[0]);
const now = new Date(); const now = new Date();
const diffInMs = now.getTime() - listedDate.getTime(); const diffInMs = now.getTime() - listedDate.getTime();
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
@@ -82,7 +101,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
{isOwner ? ( {isOwner ? (
<Select <Select
value={property.property_status} value={property.property_status}
onChange={onStatusChange} onChange={handleStatusChange}
displayEmpty displayEmpty
variant="standard" variant="standard"
sx={{ mt: 2 }} sx={{ mt: 2 }}
@@ -104,21 +123,26 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
</Box> </Box>
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around"> <Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<VisibilityIcon color="action" sx={{ mr: 1 }} /> <VisibilityIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="body1">{property.views} Views</Typography> <Typography variant="body1">{property.views} Views</Typography>
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
{isOwner ? ( {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> <Typography variant="body1">{property.saves} Saves</Typography>
</Box> </Box>
{timeOnMarketString && ( {timeOnMarketString && (
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<AccessTimeIcon color="action" sx={{ mr: 1 }} /> <AccessTimeIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="body1">{timeOnMarketString}</Typography> <Typography variant="body1">{timeOnMarketString}</Typography>
</Box> </Box>
)} )}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -3,7 +3,6 @@ import React from 'react';
import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material'; import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material';
import { VendorCategory } from 'types'; import { VendorCategory } from 'types';
interface VendorCategoryCardProps { interface VendorCategoryCardProps {
category: VendorCategory; category: VendorCategory;
onSelectCategory: (categoryId: string) => void; onSelectCategory: (categoryId: string) => void;
@@ -12,12 +11,7 @@ interface VendorCategoryCardProps {
const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => { const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => {
return ( return (
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}> <Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia <CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
component="img"
height="140"
image={category.imageUrl}
alt={category.name}
/>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="div"> <Typography gutterBottom variant="h5" component="div">
{category.name} {category.name}
@@ -30,13 +24,19 @@ const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSel
{category.categoryRating && ( {category.categoryRating && (
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Rating value={category.categoryRating} readOnly precision={0.5} size="small" /> <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>
)} )}
</Box> </Box>
</CardContent> </CardContent>
<Box sx={{ p: 2, pt: 0 }}> <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 View Vendors
</Button> </Button>
</Box> </Box>
@@ -44,4 +44,4 @@ const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSel
); );
}; };
export default VendorCategoryCard; export default VendorCategoryCard;

View File

@@ -21,19 +21,23 @@ interface VendorDetailProps {
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => { const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { account, accountLoading } = useContext(AccountContext); const { account, accountLoading } = useContext(AccountContext);
const createMessage = () => { const createMessage = async () => {
// First see if there is one already // First see if there is one already
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.get( const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
`/conversations/?vendor=${vendor.id}`, `/conversations/?vendor=${vendor.id}`,
); );
if (data === undefined) { if (data.length === 0) {
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.post(`/conversations/`, { const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.post(
property_owner: account?.id, `/conversations/`,
vendor: vendor.id, {
}); property_owner: account?.id,
vendor: vendor.id,
},
);
navigate(`/conversations/?selectedConversation=${data[0].id}`);
} else {
navigate(`/conversations/?selectedConversation=${data[0].id}`);
} }
navigate('/messages');
}; };
if (!vendor) { if (!vendor) {
return ( return (
@@ -69,7 +73,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
return ( return (
<Paper elevation={3} sx={{ p: 2 }}> <Paper elevation={3} sx={{ p: 2 }}>
<Grid container sx={{ minHeight: '100%' }}> <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}> <Box display="flex" alignItems="center" mb={2}>
<Avatar <Avatar
src={vendor.vendorImageUrl} src={vendor.vendorImageUrl}
@@ -132,7 +136,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
</Button> </Button>
)} )}
</Grid> </Grid>
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}> <Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
sx={{ sx={{
width: '100%', width: '100%',

View File

@@ -141,8 +141,11 @@ function WebSocketProvider({ children }: WebSocketProviderProps) {
ws.current.close(); 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.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 = () => { ws.current.onopen = () => {

View File

@@ -23,6 +23,13 @@ const attorneyNavItems: NavItem[] = [
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{
title: 'Search',
path: '/property-search',
icon: 'ph:magnifying-glass',
active: true,
collapsible: false,
},
{ {
title: 'Conversations', title: 'Conversations',
path: '/conversations', path: '/conversations',
@@ -37,6 +44,13 @@ const attorneyNavItems: NavItem[] = [
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
]; ];
export default attorneyNavItems; export default attorneyNavItems;

View File

@@ -25,21 +25,21 @@ const basicNavItems: NavItem[] = [
}, },
{ {
title: 'Search', title: 'Search',
path: '/property/search', path: '/property-search',
icon: 'ph:magnifying-glass', icon: 'ph:magnifying-glass',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Education', title: 'Education',
path: '/education', path: '/upgrade',
icon: 'ph:student', icon: 'ph:student',
active: false, active: false,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Vendors', title: 'Vendors',
path: '/vendors', path: '/upgrade',
icon: 'ph:storefront', icon: 'ph:storefront',
active: false, active: false,
collapsible: false, collapsible: false,
@@ -48,27 +48,19 @@ const basicNavItems: NavItem[] = [
title: 'Messages', title: 'Messages',
path: '', path: '',
icon: 'ph:chat-circle-dots', icon: 'ph:chat-circle-dots',
active: true, active: false,
collapsible: true, collapsible: true,
sublist: [ sublist: [
{ {
title: 'Offers', title: 'Documents',
path: 'offers', path: 'documents',
icon: 'ph:certificate', icon: 'ph:folder',
active: true, active: false,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Conversations', title: 'Conversations',
path: 'conversations', path: 'upgrade',
icon: 'ph:chat-circle-dots',
active: true,
collapsible: false,
},
{
title: 'Bids',
path: 'bids',
icon: 'ph:chat-circle-dots', icon: 'ph:chat-circle-dots',
active: true, active: true,
collapsible: false, collapsible: false,
@@ -84,30 +76,37 @@ const basicNavItems: NavItem[] = [
sublist: [ sublist: [
{ {
title: 'Mortgage Calculator', title: 'Mortgage Calculator',
path: '/mortgage-calculator', path: 'mortgage-calculator',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Amortization Table', title: 'Amortization Table',
path: '/amoritization-table', path: 'amoritization-table',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Home Affordability', title: 'Home Affordability',
path: '/home-affordability', path: 'home-affordability',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{ {
title: 'Net Terms Sheet', title: 'Net Terms Sheet',
path: '/net-terms-sheet', path: 'net-terms-sheet',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
], ],
}, },
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
]; ];
export default basicNavItems; export default basicNavItems;

View File

@@ -3,14 +3,14 @@ import { NavItem } from 'types';
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ {
title: 'Home', title: 'Home',
path: '/', path: '/dashboard',
icon: 'ion:home-sharp', icon: 'ion:home-sharp',
active: true, active: true,
collapsible: false, collapsible: false,
sublist: [ sublist: [
{ {
title: 'Dashboard', title: 'Dashboard',
path: '/', path: '/dasboard',
active: false, active: false,
collapsible: false, collapsible: false,
}, },
@@ -25,7 +25,7 @@ const navItems: NavItem[] = [
}, },
{ {
title: 'Search', title: 'Search',
path: '/property/search', path: '/property-search',
icon: 'ph:magnifying-glass', icon: 'ph:magnifying-glass',
active: true, active: true,
collapsible: false, collapsible: false,
@@ -52,9 +52,9 @@ const navItems: NavItem[] = [
collapsible: true, collapsible: true,
sublist: [ sublist: [
{ {
title: 'Offers', title: 'Bids',
path: 'offers', path: 'bids',
icon: 'ph:certificate', icon: 'ph:chat-circle-dots',
active: true, active: true,
collapsible: false, collapsible: false,
}, },
@@ -67,8 +67,8 @@ const navItems: NavItem[] = [
}, },
{ {
title: 'Bids', title: 'Documents',
path: 'bids', path: 'documents',
icon: 'ph:chat-circle-dots', icon: 'ph:chat-circle-dots',
active: true, active: true,
collapsible: false, collapsible: false,
@@ -108,6 +108,13 @@ const navItems: NavItem[] = [
}, },
], ],
}, },
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
]; ];
export default navItems; export default navItems;

View 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;

View File

@@ -23,6 +23,13 @@ const vendorNavItems: NavItem[] = [
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{
title: 'Search',
path: '/property-search',
icon: 'ph:magnifying-glass',
active: true,
collapsible: false,
},
{ {
title: 'Conversations', title: 'Conversations',
path: '/conversations', path: '/conversations',
@@ -37,6 +44,13 @@ const vendorNavItems: NavItem[] = [
active: true, active: true,
collapsible: false, collapsible: false,
}, },
{
title: 'Support',
path: '/support',
icon: 'ph:question',
active: true,
collapsible: false,
},
]; ];
export default vendorNavItems; export default vendorNavItems;

View File

@@ -1,149 +1,153 @@
import { ReactElement, useState } from 'react'; import { ReactElement, useState } from 'react';
import { import {
Collapse, Collapse,
LinkTypeMap, LinkTypeMap,
List, List,
ListItem, ListItem,
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
} from '@mui/material'; } from '@mui/material';
import { OverridableComponent } from '@mui/material/OverridableComponent'; import { OverridableComponent } from '@mui/material/OverridableComponent';
import IconifyIcon from 'components/base/IconifyIcon'; import IconifyIcon from 'components/base/IconifyIcon';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { NavItem } from 'data/nav-items'; import { NavItem } from 'data/nav-items';
interface NavItemProps { interface NavItemProps {
navItem: NavItem; navItem: NavItem;
Link: OverridableComponent<LinkTypeMap>; Link: OverridableComponent<LinkTypeMap>;
} }
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => { const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]); const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
const handleNestedChecked = (index: any, value: boolean) => { const handleNestedChecked = (index: any, value: boolean) => {
const updatedBooleanArray = [...nestedChecked]; const updatedBooleanArray = [...nestedChecked];
updatedBooleanArray[index] = value; updatedBooleanArray[index] = value;
setNestedChecked(updatedBooleanArray); setNestedChecked(updatedBooleanArray);
}; };
return ( const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
<ListItem const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
sx={{ const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
my: 1.25,
borderRadius: 2, return (
backgroundColor: pathname === navItem.path ? 'primary.main' : '', <ListItem
color: pathname === navItem.path ? 'common.white' : 'text.secondary', sx={{
'&:hover': { my: 1.25,
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus', borderRadius: 2,
opacity: 1.5, backgroundColor: { backgroundColor },
}, color: { color },
}} '&:hover': {
> backgroundColor: { hoverBackgroundColor },
{navItem.collapsible ? ( opacity: 1.5,
<> },
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}> }}
<ListItemIcon> >
<IconifyIcon icon={navItem.icon as string} width={1} height={1} /> {navItem.collapsible ? (
</ListItemIcon> <>
<ListItemText>{navItem.title}</ListItemText> <ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
<ListItemIcon> <ListItemIcon>
{navItem.collapsible && <IconifyIcon icon={navItem.icon as string} width={1} height={1} />
(checked ? ( </ListItemIcon>
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} /> <ListItemText>{navItem.title}</ListItemText>
) : ( <ListItemIcon>
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} /> {navItem.collapsible &&
))} (checked ? (
</ListItemIcon> <IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
</ListItemButton> ) : (
<Collapse in={checked}> <IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
<List> ))}
{navItem.sublist?.map((subListItem: any, idx: number) => ( </ListItemIcon>
<ListItem </ListItemButton>
key={idx} <Collapse in={checked}>
sx={{ <List>
backgroundColor: pathname === navItem.path ? 'primary.main' : '', {navItem.sublist?.map((subListItem: any, idx: number) => (
color: pathname === navItem.path ? 'common.white' : 'text.secondary', <ListItem
'&:hover': { key={idx}
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus', sx={{
opacity: 1.5, backgroundColor: pathname === navItem.path ? 'primary.main' : '',
}, color: pathname === navItem.path ? 'common.white' : 'text.secondary',
}} '&:hover': {
> backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
{subListItem.collapsible ? ( opacity: 1.5,
<> },
<ListItemButton }}
LinkComponent={Link} >
onClick={() => { {subListItem.collapsible ? (
handleNestedChecked(idx, !nestedChecked[idx]); <>
}} <ListItemButton
> LinkComponent={Link}
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText> onClick={() => {
<ListItemIcon> handleNestedChecked(idx, !nestedChecked[idx]);
{subListItem.collapsible && }}
(nestedChecked[idx] ? ( >
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} /> <ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
) : ( <ListItemIcon>
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} /> {subListItem.collapsible &&
))} (nestedChecked[idx] ? (
</ListItemIcon> <IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
</ListItemButton> ) : (
<Collapse in={nestedChecked[idx]}> <IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
<List> ))}
{subListItem?.sublist?.map( </ListItemIcon>
(nestedSubListItem: any, nestedIdx: number) => ( </ListItemButton>
<ListItem key={nestedIdx}> <Collapse in={nestedChecked[idx]}>
<ListItemButton <List>
LinkComponent={Link} {subListItem?.sublist?.map(
href={ (nestedSubListItem: any, nestedIdx: number) => (
navItem.path !== '/' <ListItem key={nestedIdx}>
? navItem.path + <ListItemButton
'/' + LinkComponent={Link}
subListItem.path + href={
'/' + navItem.path !== '/'
nestedSubListItem.path ? navItem.path +
: nestedSubListItem.path '/' +
} subListItem.path +
> '/' +
<ListItemText sx={{ ml: 5 }}> nestedSubListItem.path
{nestedSubListItem.title} : nestedSubListItem.path
</ListItemText> }
</ListItemButton> >
</ListItem> <ListItemText sx={{ ml: 5 }}>
), {nestedSubListItem.title}
)} </ListItemText>
</List> </ListItemButton>
</Collapse> </ListItem>
</> ),
) : ( )}
<ListItemButton </List>
LinkComponent={Link} </Collapse>
href={navItem.path + '/' + subListItem.path} </>
> ) : (
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText> <ListItemButton
</ListItemButton> LinkComponent={Link}
)} href={navItem.path + '/' + subListItem.path}
</ListItem> >
))} <ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
</List> </ListItemButton>
</Collapse> )}
</> </ListItem>
) : ( ))}
<ListItemButton </List>
LinkComponent={Link} </Collapse>
href={navItem.path} </>
sx={{ opacity: navItem.active ? 1 : 0.6 }} ) : (
> <ListItemButton
<ListItemIcon> LinkComponent={Link}
<IconifyIcon icon={navItem.icon as string} width={1} height={1} /> href={navItem.path}
</ListItemIcon> sx={{ opacity: navItem.active ? 1 : 0.6 }}
<ListItemText>{navItem.title}</ListItemText> >
</ListItemButton> <ListItemIcon>
)} <IconifyIcon icon={navItem.icon as string} width={1} height={1} />
</ListItem> </ListItemIcon>
); <ListItemText>{navItem.title}</ListItemText>
}; </ListItemButton>
)}
export default NavButton; </ListItem>
);
};
export default NavButton;

View File

@@ -24,6 +24,7 @@ import vendorNavItems from 'data/vendor-nav-items.js';
import basicNavItems from 'data/basic-nav-items.js'; import basicNavItems from 'data/basic-nav-items.js';
import { NavItem } from 'types.js'; import { NavItem } from 'types.js';
import attorneyNavItems from 'data/attorney-nav-items.js'; import attorneyNavItems from 'data/attorney-nav-items.js';
import publicNavItems from 'data/public-nav-items.js';
const Sidebar = (): ReactElement => { const Sidebar = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,6 +44,8 @@ const Sidebar = (): ReactElement => {
} else if (account.user_type === 'attorney') { } else if (account.user_type === 'attorney') {
nav_items = attorneyNavItems; nav_items = attorneyNavItems;
} }
} else {
nav_items = publicNavItems;
} }
const handleSignOut = async () => { const handleSignOut = async () => {

View 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;

View 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;

View 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;

View File

@@ -56,7 +56,7 @@ const BidsPage: React.FC = () => {
<Grid container spacing={3} sx={{ mt: 3 }}> <Grid container spacing={3} sx={{ mt: 3 }}>
{bids.map((bid) => ( {bids.map((bid) => (
<Grid item xs={12} key={bid.id}> <Grid size={{ xs: 12 }} key={bid.id}>
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} /> <BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
</Grid> </Grid>
))} ))}

View File

@@ -1,31 +1,85 @@
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useEffect, useState } from 'react';
import {axiosInstance} from '../../axiosApi' import { axiosInstance } from '../../axiosApi';
import DashboardTemplate from 'components/DasboardTemplate'; import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate'; import CategoryGridTemplate from 'components/CategoryGridTemplate';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate'; import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer'; 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 VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem'; import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
import { AxiosResponse } from 'axios';
const Education = (): ReactElement => { const Education = (): ReactElement => {
const [allVideos, setAllVideos] = useState<VideoItem[]>([]); const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]); const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
// Simulate fetching data from backend const updateVideoCategories = (videos: VideoItem[]) => {
let fetchedVideos: 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(() => { useEffect(() => {
// In a real app, you'd make an API call here // In a real app, you'd make an API call here
const fetchVideos = async () => { const fetchVideos = async () => {
// Replace with your actual API call // Replace with your actual API call
try{ try {
const { data }: AxiosResponse<VideoProgressAPI[]> =
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/') await axiosInstance.get('/videos/progress/');
if(data.length > 0){ if (data.length > 0) {
fetchedVideos = data.map(item => { fetchedVideos = data.map((item) => {
console.log(item)
return { return {
id: String(item.video.id), id: String(item.video.id),
progress_id: item.id, progress_id: item.id,
@@ -37,75 +91,45 @@ const Education = (): ReactElement => {
progress: item.progress, progress: item.progress,
videoUrl: item.video.link, videoUrl: item.video.link,
duration: item.video.duration, 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)!; } catch (error) {
categoryData.total += 1; console.log('there was an error', error);
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);
updateVideoCategories(fetchedVideos);
}; };
fetchVideos(); fetchVideos();
}, []); }, []);
const updateVideoItem = async (
time: number,
completed: boolean,
progress: number,
videoId: number,
// const handleSelectCategory = (categoryName: string) => { ) => {
// setSelectedCategory(categoryName); 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 handleBackToCategories = () => { return (
// setSelectedCategory(null); <DashboardTemplate<VideoCategory, VideoItem>
// };
return(
<DashboardTemplate<VideoCategory, VideoItem>
pageTitle="Educational Videos" pageTitle="Educational Videos"
data={{ categories: videoCategories, items: allVideos }} data={{ categories: videoCategories, items: allVideos }}
renderCategoryGrid={(categories, onSelectCategory) => ( renderCategoryGrid={(categories, onSelectCategory) => (
@@ -123,39 +147,22 @@ const Education = (): ReactElement => {
items={itemsInSelectedCategory} items={itemsInSelectedCategory}
onBack={onBack} onBack={onBack}
renderListItem={(item, isSelected, onSelect) => ( renderListItem={(item, isSelected, onSelect) => (
<VideoListItem video={item as VideoItem} isSelected={isSelected} <VideoListItem
onSelect={() => { video={item as VideoItem}
console.log('selecting') isSelected={isSelected}
onSelect(item.id)} onSelect={() => {
} /> console.log('selecting');
onSelect(item.id);
}}
/>
)} )}
renderItemDetail={(item) => ( renderItemDetail={(item) => (
<VideoPlayer video={item as VideoItem} /> <VideoPlayer video={item as VideoItem} updateVideoItem={updateVideoItem} />
)} )}
/> />
)} )}
/> />
);
};
) export default Education;
// (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;

View File

@@ -1,4 +1,5 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react'; import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { drawerWidth } from 'layouts/main-layout'; import { drawerWidth } from 'layouts/main-layout';
import { import {
@@ -19,6 +20,11 @@ import { axiosInstance } from '../../axiosApi';
import { import {
Box, Box,
Container, Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Autocomplete,
List, List,
Grid, Grid,
ListItem, ListItem,
@@ -36,6 +42,7 @@ import { AxiosResponse } from 'axios';
import { AccountContext } from 'contexts/AccountContext'; import { AccountContext } from 'contexts/AccountContext';
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js'; import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
import { formatTimestamp } from 'utils'; import { formatTimestamp } from 'utils';
import CreateConversationDialogContent from 'components/sections/dashboard/Home/Messages/CreateConversationDialogContent';
interface Message { interface Message {
id: number; id: number;
@@ -53,14 +60,38 @@ interface Conversation {
} }
const Messages = (): ReactElement => { const Messages = (): ReactElement => {
const [searchParams] = useSearchParams();
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null); const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
const [newMessageContent, setNewMessageContent] = useState<string>(''); const [newMessageContent, setNewMessageContent] = useState<string>('');
const { account } = useContext(AccountContext); 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 // Auto-scroll to the bottom of the messages when they update
const messagesEndRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
const fetchConversations = async () => { const fetchConversations = async () => {
try { try {
@@ -103,6 +134,72 @@ const Messages = (): ReactElement => {
} }
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update }, [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); const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
// Handle sending a new message // Handle sending a new message
@@ -147,19 +244,16 @@ const Messages = (): ReactElement => {
return ( return (
<Container <Container
maxWidth="lg" sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
> >
<Paper <Paper
elevation={3} elevation={3}
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }} 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 */} {/* Left Panel: Conversation List */}
<Grid <Grid
item size={{ xs: 12, md: 4 }}
xs={12}
md={4}
sx={{ sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' }, borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex', display: 'flex',
@@ -172,8 +266,13 @@ const Messages = (): ReactElement => {
Conversations Conversations
</Typography> </Typography>
{account?.user_type === 'property_owner' && ( {account?.user_type === 'property_owner' && (
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}> <Button
New Conversation variant="contained"
color="primary"
sx={{ ml: 'auto' }}
onClick={handleOpenCreateConversationDialog}
>
Create
</Button> </Button>
)} )}
</Stack> </Stack>
@@ -234,7 +333,7 @@ const Messages = (): ReactElement => {
</Grid> </Grid>
{/* Right Panel: Conversation Detail */} {/* 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 ? ( {selectedConversation ? (
<> <>
{/* Conversation Header */} {/* Conversation Header */}
@@ -355,6 +454,14 @@ const Messages = (): ReactElement => {
</Grid> </Grid>
</Grid> </Grid>
</Paper> </Paper>
<CreateConversationDialogContent
showDialog={openCreateConversationDialog}
closeDialog={handleCloseCreateConversationDialog}
createConversation={handleCreateConversation}
vendors={vendors}
setSelectedVendor={setSelectedVendor}
selectedVendor={selectedVendor}
/>
</Container> </Container>
); );
}; };

View File

@@ -1,15 +1,44 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react'; import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { drawerWidth } from 'layouts/main-layout'; 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 DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate'; import CategoryGridTemplate from 'components/CategoryGridTemplate';
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard'; import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate'; import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem'; import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail'; import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import {axiosInstance} from '../../axiosApi' 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 {
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 LocalOffer from '@mui/icons-material/ChatBubbleOutline';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
@@ -37,304 +66,348 @@ interface Offer {
lastMessageTimestamp: string; lastMessageTimestamp: string;
market_value: string; market_value: string;
offer_value: string; offer_value: string;
} }
type submitOfferProps = { type submitOfferProps = {
offer_id: number, offer_id: number;
sender_id: number, sender_id: number;
property_id: number, property_id: number;
} };
const Offers = (): ReactElement => { const Offers = (): ReactElement => {
const [offers, setOffers] = useState<Offer[]>([]); const [offers, setOffers] = useState<Offer[]>([]);
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null); const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
const {account} = useContext(AccountContext) const { account } = useContext(AccountContext);
const [showDialog, setShowDialog] = useState<boolean>(false); const [showDialog, setShowDialog] = useState<boolean>(false);
const closeDialog = () => { const closeDialog = () => {
setShowDialog(false); setShowDialog(false);
} };
const createOffer = async (property_id: number) => { const createOffer = async (property_id: number) => {
console.log(account) console.log(account);
if(account) if (account) {
{
console.log({ console.log({
user: account.id, user: account.id,
property: property_id, property: property_id,
});
response = await axiosInstance.post(`/offers/`, {
})
response = await axiosInstance.post(`/offers/`,
{
user: account.id, user: account.id,
property: property_id, property: property_id,
});
})
setShowDialog(false); setShowDialog(false);
setShowDialog(false)
setShowDialog(false);
} }
};
}
const submitOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {
const submitOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => { response = await axiosInstance.put(`/offers/${offer_id}/`, {
response = await axiosInstance.put(`/offers/${offer_id}/`, user: sender_id,
{ property: property_id,
user: sender_id, status: 'submitted',
property: property_id, });
status:'submitted' console.log(response);
})
console.log(response)
// TODO: update the selectedOffer' status // TODO: update the selectedOffer' status
const updatedOffers: Offer[] = offers.map(item => ({ const updatedOffers: Offer[] = offers.map((item) => ({
...item, // Spread operator to copy existing properties ...item, // Spread operator to copy existing properties
status: 'submitted' status: 'submitted',
})); }));
setOffers(updatedOffers); setOffers(updatedOffers);
} };
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
}
const withdrawOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {};
useEffect(() => { useEffect(() => {
const fetchOffers = async () => { const fetchOffers = async () => {
try{ try {
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/') const { data }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/');
console.log(data) console.log(data);
if (data.length > 0){ if (data.length > 0) {
console.log(data) console.log(data);
const fetchedOffers: Offer[] = data.map(item => { const fetchedOffers: Offer[] = data.map((item) => {
console.log(item);
console.log(item) return {
return { id: item.id,
id: item.id, sender: item.user.first_name + ' ' + item.user.last_name,
sender: item.user.first_name + " " + item.user.last_name, status: item.status,
status: item.status, address: item.property.address,
address: item.property.address, is_active: item.is_active,
is_active: item.is_active, lastMessageTimestamp: item.updated_at,
lastMessageTimestamp: item.updated_at, market_value: item.property.market_value,
market_value: item.property.market_value, offer_value: '100000',
offer_value: '100000', sender_id: item.user.id,
sender_id: item.user.id, property_id: item.property.id,
property_id: item.property.id };
});
} console.log(fetchedOffers);
}) setOffers(fetchedOffers);
console.log(fetchedOffers)
setOffers(fetchedOffers);
}
}catch(error){
} }
} } catch (error) {}
};
fetchOffers(); fetchOffers();
}, []) }, []);
type offerChoice = 'accept' | 'counter' | 'reject'; type offerChoice = 'accept' | 'counter' | 'reject';
const handleOffer = async (choice: offerChoice) => { const handleOffer = async (choice: offerChoice) => {
console.log(choice) console.log(choice);
} };
const selectedOffer = offers.find( const selectedOffer = offers.find((conv) => conv.id === selectedOfferId);
(conv) => conv.id === selectedOfferId
);
return ( return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}> <Container
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}> maxWidth="lg"
<Grid container sx={{ height: '100%' }}> sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
{/* Left Panel: Offer List */} >
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}> <Paper
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}> elevation={3}
<Stack direction="row" sx={{width:'100%'}}> 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' }}> <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
Offers Offers
</Typography> </Typography>
<Button <Button
variant='contained' variant="contained"
color='primary' color="primary"
sx={{ml:'auto'}} sx={{ ml: 'auto' }}
onClick={() => setShowDialog(true)} onClick={() => setShowDialog(true)}
> >
Create Offer Create Offer
</Button> </Button>
</Stack> </Stack>
</Box> </Box>
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}> <List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
{offers.length === 0 ? ( {offers.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}> <Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
<LocalOffer sx={{ fontSize: 40, mb: 1 }} /> <LocalOffer sx={{ fontSize: 40, mb: 1 }} />
<Typography>No offers submited yet.</Typography> <Typography>No offers submited yet.</Typography>
</Box>
) : (
offers
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
.map((conv) => (
<ListItem
key={conv.id}
button
selected={selectedOfferId === conv.id}
onClick={() => setSelectedOfferId(conv.id)}
sx={{ py: 1.5, px: 2 }}
>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{conv.sender}
</Typography>
}
secondary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
{conv.address}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatTimestamp(conv.lastMessageTimestamp)}
</Typography>
</Box>
}
/>
</ListItem>
))
)}
</List>
</Grid>
{/* Right Panel: Offer Detail */}
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
{selectedOffer ? (
<>
{/* Offer Header */}
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar sx={{ bgcolor: 'purple.200'}}>
{selectedOffer.sender.split(' ').map(n => n[0]).join('')}
</Avatar>
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{selectedOffer.sender}
</Typography>
</Box>
{/* Messages Area */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
{/* add the offer details here */}
<Accordion>
<AccordionSummary>
Offer for {selectedOffer.address}
</AccordionSummary>
<AccordionDetails>
<Typography>
Offer Price: <strong>{selectedOffer.offer_value}</strong>
</Typography>
<Typography>
Waived Inspection: <strong>No</strong>
</Typography>
<Typography>
Closing date: <strong>90 days</strong>
</Typography>
<Typography>
Status: <strong>{selectedOffer.status}</strong>
</Typography>
</AccordionDetails>
<AccordionActions>
{selectedOffer.status === 'submitted' ? (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
color="primary"
onClick={async() => handleOffer('accept')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Accept
</Button>
<Button
variant="contained"
color="primary"
onClick={async() => handleOffer('counter')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Counter
</Button>
<Button
variant="contained"
color="primary"
onClick={ async() => handleOffer('reject')}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Reject
</Button>
</Box>
): (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
color="primary"
onClick={async() => withdrawOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
endIcon={<DeleteForeverIcon />}
sx={{ px: 3, py: 1.2 }}
>
Withdraw Offer
</Button>
<Button
variant="contained"
color="primary"
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
endIcon={<SendIcon />}
sx={{ px: 3, py: 1.2 }}
>
Submit
</Button>
</Box>
)}
</AccordionActions>
</Accordion>
</Box>
{/* Message Input */}
</>
) : (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6">Select an offer to view</Typography>
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
</Box> </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> </Grid>
<CreateOfferDialog showDialog={showDialog} createOffer={createOffer} closeDialog={closeDialog} />
</Paper>
</Container>
);
}
export default Offers; {/* 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;

View File

@@ -4,165 +4,238 @@ import { PropertiesAPI } from 'types';
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters'; import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem'; import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
// Reusing the mockProperties from PropertyDetailPage for consistent data // Reusing the mockProperties from PropertyDetailPage for consistent data
const mockProperties: PropertiesAPI[] = [ const mockProperties: PropertiesAPI[] = [
{ {
id: 101, 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' }, owner: {
address: '123 Main St', user: {
city: 'Anytown', id: 1,
state: 'CA', email: 'john.doe@example.com',
zip_code: '90210', first_name: 'John',
market_value: '500000', last_name: 'Doe',
loan_amount: '300000', user_type: 'property_owner',
loan_term: 30, is_active: true,
loan_start_date: '2020-05-01', date_joined: '2023-01-15',
created_at: '2020-04-20', tos_signed: true,
last_updated: '2023-10-10', profile_created: true,
pictures: [ tier: 'basic',
'https://via.placeholder.com/600x400?text=Property+1+Exterior', },
'https://via.placeholder.com/600x400?text=Property+1+Living', phone_number: '123-456-7890',
],
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,
}, },
{ address: '123 Main St',
id: 102, city: 'Anytown',
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' }, state: 'CA',
address: '456 Oak Ave', zip_code: '90210',
city: 'Anytown', market_value: '500000',
state: 'CA', loan_amount: '300000',
zip_code: '90210', loan_term: 30,
market_value: '750000', loan_start_date: '2020-05-01',
loan_amount: '500000', created_at: '2020-04-20',
loan_term: 20, last_updated: '2023-10-10',
loan_start_date: '2022-01-10', pictures: [
created_at: '2021-12-01', 'https://via.placeholder.com/600x400?text=Property+1+Exterior',
last_updated: '2023-11-20', 'https://via.placeholder.com/600x400?text=Property+1+Living',
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'], ],
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.', description:
sq_ft: 2500, 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
features: ['Pool', 'Fireplace', 'Large Yard'], sq_ft: 1800,
num_bedrooms: 4, features: ['Garage', 'Central AC', 'Hardwood Floors'],
num_bathrooms: 3, num_bedrooms: 3,
latitude: 34.075, // Another example coordinate num_bathrooms: 2,
longitude: -118.30, latitude: 34.0522, // Example coordinates for Los Angeles
longitude: -118.2437,
},
{
id: 102,
owner: {
user: {
id: 1,
email: 'john.doe@example.com',
first_name: 'John',
last_name: 'Doe',
user_type: 'property_owner',
is_active: true,
date_joined: '2023-01-15',
tos_signed: true,
profile_created: true,
tier: 'basic',
},
phone_number: '123-456-7890',
}, },
{ address: '456 Oak Ave',
id: 103, city: 'Anytown',
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' }, state: 'CA',
address: '789 Pine Lane', zip_code: '90210',
city: 'Otherville', market_value: '750000',
state: 'NY', loan_amount: '500000',
zip_code: '10001', loan_term: 20,
market_value: '1200000', loan_start_date: '2022-01-10',
loan_amount: '800000', created_at: '2021-12-01',
loan_term: 15, last_updated: '2023-11-20',
loan_start_date: '2021-03-20', pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
created_at: '2021-02-15', description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
last_updated: '2024-01-05', sq_ft: 2500,
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'], features: ['Pool', 'Fireplace', 'Large Yard'],
description: 'Luxury apartment in the heart of the city with stunning views.', num_bedrooms: 4,
sq_ft: 1200, num_bathrooms: 3,
features: ['City View', 'Gym Access', 'Doorman'], latitude: 34.075, // Another example coordinate
num_bedrooms: 2, longitude: -118.3,
num_bathrooms: 2, },
latitude: 40.7128, // NYC {
longitude: -74.0060, 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',
id: 104, city: 'Otherville',
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' }, state: 'NY',
address: '101 Elm Street', zip_code: '10001',
city: 'Sampleton', market_value: '1200000',
state: 'TX', loan_amount: '800000',
zip_code: '75001', loan_term: 15,
market_value: '350000', loan_start_date: '2021-03-20',
loan_amount: '250000', created_at: '2021-02-15',
loan_term: 30, last_updated: '2024-01-05',
loan_start_date: '2023-07-01', pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
created_at: '2023-06-20', description: 'Luxury apartment in the heart of the city with stunning views.',
last_updated: '2024-06-15', sq_ft: 1200,
pictures: ['https://via.placeholder.com/600x400?text=TX+House'], features: ['City View', 'Gym Access', 'Doorman'],
description: 'Cozy starter home with a large yard, ideal for families.', num_bedrooms: 2,
sq_ft: 1500, num_bathrooms: 2,
features: ['Large Yard', 'New Roof'], latitude: 40.7128, // NYC
num_bedrooms: 3, longitude: -74.006,
num_bathrooms: 2, },
latitude: 32.7767, // Dallas {
longitude: -96.7970, 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 Property: React.FC = () => {
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]); const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
const [initialLoad, setInitialLoad] = useState(true); const [initialLoad, setInitialLoad] = useState(true);
const filterProperties = (filters: any) => { const filterProperties = (filters: any) => {
const filtered = mockProperties.filter(property => { const filtered = mockProperties.filter((property) => {
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true; const addressMatch = filters.address
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true; ? property.address.toLowerCase().includes(filters.address.toLowerCase())
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true; : true;
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : 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) && const sqFtMatch =
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt); (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) && (filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms); const bedroomsMatch =
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) && (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms); (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 && return (
sqFtMatch && bedroomsMatch && bathroomsMatch; addressMatch &&
}); cityMatch &&
setSearchResults(filtered); stateMatch &&
setInitialLoad(false); zipCodeMatch &&
}; sqFtMatch &&
bedroomsMatch &&
bathroomsMatch
);
});
setSearchResults(filtered);
setInitialLoad(false);
};
const handleSearch = (filters: any) => { const handleSearch = (filters: any) => {
filterProperties(filters); filterProperties(filters);
}; };
const handleClearSearch = () => { const handleClearSearch = () => {
setSearchResults([]); // Clear results on clear setSearchResults([]); // Clear results on clear
setInitialLoad(true); // Reset to initial state setInitialLoad(true); // Reset to initial state
}; };
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom> <Typography variant="h4" component="h1" gutterBottom>
Property Search Property Search
</Typography> </Typography>
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} /> <PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}> <Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`} {initialLoad
</Typography> ? 'Enter search criteria to find properties.'
<Grid container spacing={2}> : `Search Results (${searchResults.length} found)`}
{searchResults.length === 0 && !initialLoad ? ( </Typography>
<Grid item xs={12}> <Grid container spacing={2}>
<Alert severity="info">No properties found matching your criteria.</Alert> {searchResults.length === 0 && !initialLoad ? (
</Grid> <Grid size={{ xs: 12 }}>
) : ( <Alert severity="info">No properties found matching your criteria.</Alert>
searchResults.map(property => ( </Grid>
<Grid item xs={12} sm={6} md={4} key={property.id}> ) : (
<PropertyListItem searchResults.map((property) => (
property={property} <Grid size={{ xs: 12, sm: 6, md: 4 }} key={property.id}>
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate <PropertyListItem
/> property={property}
</Grid> onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
)) />
)} </Grid>
</Grid> ))
</Container> )}
); </Grid>
</Container>
);
}; };
export default Property; export default Property;
@@ -185,7 +258,6 @@ export default Property;
// import HouseIcon from '@mui/icons-material/House'; // import HouseIcon from '@mui/icons-material/House';
// import { formatTimestamp } from 'utils'; // import { formatTimestamp } from 'utils';
// const Property = (): ReactElement => { // const Property = (): ReactElement => {
// const [properties, setProperties] = useState<PropertiesAPI[]>([]); // const [properties, setProperties] = useState<PropertiesAPI[]>([]);
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null) // const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
@@ -202,7 +274,6 @@ export default Property;
// setSelectedPropertyId(data[0].id) // setSelectedPropertyId(data[0].id)
// } // }
// }catch(error){ // }catch(error){
// console.log(error) // console.log(error)
@@ -214,7 +285,7 @@ export default Property;
// const selectedProperty = properties.find( // const selectedProperty = properties.find(
// (property) => property.id === selectedPropertyId // (property) => property.id === selectedPropertyId
// ); // );
// return( // return(
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> // <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}> // <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
@@ -223,8 +294,7 @@ export default Property;
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}> // <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' }}> // <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
// <Stack direction="row" sx={{width:'100%'}}> // <Stack direction="row" sx={{width:'100%'}}>
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}> // <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
// Properties // Properties
// </Typography> // </Typography>
@@ -237,7 +307,7 @@ export default Property;
// <Typography>No properties yet. Go to profile to add one.</Typography> // <Typography>No properties yet. Go to profile to add one.</Typography>
// </Box> // </Box>
// ) : ( // ) : (
// properties // properties
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) // .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
// .map((conv) => ( // .map((conv) => (
@@ -270,7 +340,7 @@ export default Property;
// )} // )}
// </List> // </List>
// </Grid> // </Grid>
// {/* Right Panel: Conversation Detail */} // {/* Right Panel: Conversation Detail */}
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}> // <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
// {selectedProperty ? ( // {selectedProperty ? (
@@ -284,7 +354,7 @@ export default Property;
// {selectedProperty.address} // {selectedProperty.address}
// </Typography> // </Typography>
// </Box> // </Box>
// {/* Messages Area */} // {/* Messages Area */}
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}> // <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
// <Grid // <Grid
@@ -302,12 +372,12 @@ export default Property;
// }} // }}
// > // >
// <Grid xs={12} md={8}> // <Grid xs={12} md={8}>
// <PropertyDetailsCard /> // <PropertyDetailsCard />
// </Grid> // </Grid>
// <Grid xs={12} md={4}> // <Grid xs={12} md={4}>
// <HomePriceEstimate /> // <HomePriceEstimate />
// </Grid> // </Grid>
// <Grid xs={12} md={8}> // <Grid xs={12} md={8}>
// <PhotoGalleryCard /> // <PhotoGalleryCard />
@@ -315,25 +385,24 @@ export default Property;
// </Grid> // </Grid>
// <Grid xs={12} md={4}> // <Grid xs={12} md={4}>
// <MarketStatistics /> // <MarketStatistics />
// </Grid> // </Grid>
// <Grid xs={12} md={8}> // <Grid xs={12} md={8}>
// <PropertyValueGraphCard /> // <PropertyValueGraphCard />
// </Grid> // </Grid>
// <Grid xs={12} md={4}> // <Grid xs={12} md={4}>
// <LoanDetailsCard /> // <LoanDetailsCard />
// </Grid> // </Grid>
// <Grid xs={12} md={4}> // <Grid xs={12} md={4}>
// <PropertyListingCard /> // <PropertyListingCard />
// </Grid> // </Grid>
// </Grid> // </Grid>
// </Box> // </Box>
// </> // </>
// ) : ( // ) : (
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}> // <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
@@ -364,12 +433,12 @@ export default Property;
// // }} // // }}
// // > // // >
// // <Grid xs={12} md={8}> // // <Grid xs={12} md={8}>
// // <PropertyDetailsCard /> // // <PropertyDetailsCard />
// // </Grid> // // </Grid>
// // <Grid xs={12} md={4}> // // <Grid xs={12} md={4}>
// // <HomePriceEstimate /> // // <HomePriceEstimate />
// // </Grid> // // </Grid>
// // <Grid xs={12} md={8}> // // <Grid xs={12} md={8}>
// // <PhotoGalleryCard /> // // <PhotoGalleryCard />
@@ -377,22 +446,22 @@ export default Property;
// // </Grid> // // </Grid>
// // <Grid xs={12} md={4}> // // <Grid xs={12} md={4}>
// // <MarketStatistics /> // // <MarketStatistics />
// // </Grid> // // </Grid>
// // <Grid xs={12} md={8}> // // <Grid xs={12} md={8}>
// // <PropertyValueGraphCard /> // // <PropertyValueGraphCard />
// // </Grid> // // </Grid>
// // <Grid xs={12} md={4}> // // <Grid xs={12} md={4}>
// // <LoanDetailsCard /> // // <LoanDetailsCard />
// // </Grid> // // </Grid>
// // <Grid xs={12} md={4}> // // <Grid xs={12} md={4}>
// // <PropertyListingCard /> // // <PropertyListingCard />
// // </Grid> // // </Grid>
// // </Grid> // // </Grid>
// // ) // // )
// } // }
// export default Property; // export default Property;

View File

@@ -1,7 +1,18 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material'; import {
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types'; 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 PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard'; import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard'; 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 PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard'; import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard'; import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
import { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi'; import { axiosInstance } from '../../axiosApi';
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard'; import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
import { AccountContext } from 'contexts/AccountContext'; import { AccountContext } from 'contexts/AccountContext';
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
const PropertyDetailPage: React.FC = () => { const PropertyDetailPage: React.FC = () => {
// In a real app, you'd get propertyId from URL params or a global state // In a real app, you'd get propertyId from URL params or a global state
const { account, accountLoading } = useContext(AccountContext); const { account, accountLoading } = useContext(AccountContext);
const { propertyId } = useParams<{ propertyId: string }>(); const { propertyId } = useParams<{ propertyId: string }>();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const isSearch = searchParams.get('search') === '1'; const isSearch = searchParams.get('search') === '1';
@@ -26,175 +39,282 @@ const PropertyDetailPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [savedProperty, setSavedProperty] = useState<SavedPropertiesAPI | null>(null);
if (accountLoading) { useEffect(() => {
return <>Page is loading</>; // Simulate API call
} else if (!accountLoading && !account) { const getProperty = async () => {
return <>There was an error</>;
} else {
useEffect(() => {
// Simulate API call
const getProperty = async () => {
try {
setLoading(true);
setError(null);
const url = isSearch
? `/properties/${propertyId}/?search=1`
: `/properties/${propertyId}/`;
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
if (isSearch) {
// kick the view count
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
}
if (data !== undefined) {
setProperty(data);
}
} catch {
setError('Property not found.');
} finally {
setLoading(false);
}
};
getProperty();
}, [propertyId]);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property
console.log('Saving property:', updatedProperty);
setProperty(updatedProperty);
setMessage({ type: 'success', text: 'Property details updated successfully!' });
setTimeout(() => setMessage(null), 3000);
};
const onStatusChange = () => {
if (property) {
setProperty((property) => ({ ...property, status: 'active' }));
}
};
const onSavedPropertySave = async () => {
try { try {
const response = await axiosInstance.post(`/saved-properties/`, { setLoading(true);
property: property.id, setError(null);
user: account.id, const url = isSearch ? `/properties/${propertyId}/?search=1` : `/properties/${propertyId}/`;
}); const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
console.log(response); if (isSearch) {
} catch (error) { // kick the view count
console.log(error); await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
}
if (data !== undefined) {
setProperty(data);
}
} catch {
setError('Property not found.');
} finally {
setLoading(false);
} }
}; };
const getSavedProperties = async () => {
const handleDeleteProperty = (propertyId: number) => { if (account) {
console.log('handle delete. IMPLEMENT ME'); // 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) => { const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`); // In a real app, this would be an API call to update the property
// Here you would send the offer to your backend API console.log('Saving property:', updatedProperty);
}; setProperty(updatedProperty);
setMessage({ type: 'success', text: 'Property details updated successfully!' });
setTimeout(() => setMessage(null), 3000);
};
if (loading) { const onStatusChange = async (value: string) => {
return ( if (property) {
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}> try {
<CircularProgress /> await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, {
<Typography>Loading property details...</Typography> property_status: value,
</Container> });
); setProperty((prev) => (prev ? { ...prev, property_status: value } : null));
} setMessage({ type: 'success', text: `Your listing is now ${value}` });
} catch (error) {
if (error) { setMessage({
return ( type: 'error',
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> text: 'There was an error saving your selection. Please try again',
<Alert severity="error">{error}</Alert> });
</Container> setTimeout(() => setMessage(null), 3000);
); }
}
if (!property) {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Alert severity="info">No property data available.</Alert>
</Container>
);
}
// Determine if the current user is the owner of this property
const isOwnerOfProperty = account.id === property.owner.user.id;
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
let listed_price: string;
if (property.listed_price === undefined || property.listed_price === null) {
listed_price = 'No Price';
} else { } 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 ( return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
{message && ( <CircularProgress />
<Alert severity={message.type} sx={{ mb: 2 }}> <Typography>Loading property details...</Typography>
{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> </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; export default PropertyDetailPage;

View File

@@ -3,7 +3,7 @@ import { Container, Typography, Box, Grid, Alert } from '@mui/material';
import { PropertiesAPI } from 'types'; import { PropertiesAPI } from 'types';
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters'; import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem'; 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 MapSerachComponent from 'components/base/MapSearchComponent';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi'; import { axiosInstance } from '../../axiosApi';
@@ -125,7 +125,7 @@ const PropertySearchPage: React.FC = () => {
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Property List Section */} {/* Property List Section */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}> <Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}> <Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`} {initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
@@ -149,7 +149,7 @@ const PropertySearchPage: React.FC = () => {
</Grid> </Grid>
{/* Map Section */} {/* Map Section */}
<Grid item xs={12} md={6}> <Grid size={{ xs: 12, md: 6 }}>
<MapSerachComponent <MapSerachComponent
center={mapState.center} center={mapState.center}
zoom={mapState.zoom} zoom={mapState.zoom}

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,4 +1,4 @@
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useEffect, useState, useMemo } from 'react';
import { drawerWidth } from 'layouts/main-layout'; import { drawerWidth } from 'layouts/main-layout';
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types'; import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
import DashboardTemplate from 'components/DasboardTemplate'; import DashboardTemplate from 'components/DasboardTemplate';
@@ -8,10 +8,145 @@ import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem'; import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail'; import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import { axiosInstance } from '../../axiosApi'; 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 Vendors = (): ReactElement => {
const [allVendors, setAllVendors] = useState<VendorItem[]>([]); const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]); const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
const [hideEmptyCategories, setHideEmptyCategories] = useState<boolean>(true); // Default to true
const [searchRadius, setSearchRadius] = useState<number>(10);
// Simulate fetching data // Simulate fetching data
let fetchedVendors: VendorItem[] = []; let fetchedVendors: VendorItem[] = [];
@@ -51,42 +186,26 @@ const Vendors = (): ReactElement => {
} }
>(); >();
const defaultCategoryImages: { [key: string]: string } = { CATEGORY_NAMES.forEach((categoryName) => {
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician', const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber', categoryMap.set(categoryId, {
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping', 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) => { fetchedVendors.forEach((vendor) => {
const categoryId = vendor.categoryId; const categoryId = vendor.categoryId;
if (!categoryMap.has(categoryId)) { if (categoryMap.has(categoryId)) {
let categoryName = ''; const categoryData = categoryMap.get(categoryId)!;
switch (categoryId) { categoryData.numVendors += 1;
case 'electrician': categoryData.totalRating += vendor.rating;
categoryName = 'Electricians';
break;
case 'plumber':
categoryName = 'Plumbers';
break;
case 'landscaping':
categoryName = 'Landscaping';
break;
default:
categoryName = 'Other Service';
}
categoryMap.set(categoryId, {
name: categoryName,
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
imageUrl:
defaultCategoryImages[categoryId] ||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
numVendors: 0,
totalRating: 0,
});
} }
const categoryData = categoryMap.get(categoryId)!;
categoryData.numVendors += 1;
categoryData.totalRating += vendor.rating;
}); });
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map( const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
@@ -105,37 +224,110 @@ const Vendors = (): ReactElement => {
fetchData(); 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 ( return (
<DashboardTemplate<VendorCategory, VendorItem> <Container maxWidth="lg" sx={{ mt: 4 }}>
pageTitle="Service Vendors" {/* VENDOR FILTERS HEADER AND CONTROLS */}
data={{ categories: vendorCategories, items: allVendors }} <Paper
renderCategoryGrid={(categories, onSelectCategory) => ( sx={{
<CategoryGridTemplate display: 'flex',
categories={categories} justifyContent: 'space-between',
onSelectCategory={(id) => onSelectCategory(id)} alignItems: 'center',
renderCategoryCard={(category, onSelect) => ( mb: 2,
<VendorCategoryCard category={category as VendorCategory} onSelectCategory={onSelect} /> flexWrap: 'wrap',
)} }}
/> >
)} <Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => ( Vendor Filters
<ItemListDetailTemplate </Typography>
category={selectedCategory} <Box
items={itemsInSelectedCategory} sx={{
onBack={onBack} display: 'flex',
renderListItem={(item, isSelected, onSelect) => ( gap: 2,
<VendorListItem alignItems: 'center',
vendor={item as VendorItem} mt: { xs: 1, sm: 0 },
isSelected={isSelected} }}
onSelect={() => onSelect(item.id)} >
/> {/* TOGGLE: Hide Categories with No Vendors */}
)} <FormControlLabel
renderItemDetail={(item) => ( control={
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} /> <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>
); );
}; };

View File

@@ -1,210 +1,205 @@
import { ReactElement, Suspense, useContext, useState } from 'react'; import { ReactElement, Suspense, useContext, useState } from 'react';
import { import {
Alert, Alert,
Button, Button,
FormControl, FormControl,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel, InputLabel,
Link, Link,
Skeleton, Skeleton,
Stack, Stack,
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import loginBanner from 'assets/authentication-banners/green.png'; import loginBanner from 'assets/authentication-banners/green.png';
import IconifyIcon from 'components/base/IconifyIcon'; import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png'; import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import{axiosInstance} from '../../axiosApi.js'; import { axiosInstance } from '../../axiosApi.js';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Form, Formik } from 'formik'; import { Form, Formik } from 'formik';
import { AuthContext } from 'contexts/AuthContext.js'; import { AuthContext } from 'contexts/AuthContext.js';
type loginValues = { type loginValues = {
email: string; email: string;
password: string; password: string;
} };
const Login = (): ReactElement => {
const Login = (): ReactElement => { const [showPassword, setShowPassword] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const [errorMessage, setErrorMessage] = useState<any | null>(null);
const [errorMessage, setErrorMessage] = useState<any | null>(null); const navigate = useNavigate();
const navigate = useNavigate(); const { setAuthentication } = useContext(AuthContext);
const {setAuthentication} = useContext(AuthContext);
const handleLogin = async ({ email, password }: loginValues): Promise<void> => {
const handleLogin = async({email, password}: loginValues): Promise<void> => { try {
try{ const response = await axiosInstance.post('/token/', {
const response = await axiosInstance.post('/token/', email: email,
{ password: password,
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);
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access; const get_user_response = await axiosInstance.get('/user/');
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh); setAuthentication(true);
const get_user_response = await axiosInstance.get('/user/')
navigate('/dashboard');
setAuthentication(true) } catch (error) {
const hasErrors = Object.keys(error.response.data).length > 0;
navigate("/") if (hasErrors) {
setErrorMessage(error.response.data);
} else {
setErrorMessage(null);
}catch (error) { }
const hasErrors = Object.keys(error.response.data).length > 0; }
if (hasErrors) { };
setErrorMessage(error.response.data)
}else{ return (
setErrorMessage(null); <Stack
} direction="row"
} bgcolor="background.paper"
} boxShadow={(theme) => theme.shadows[3]}
height={560}
return ( width={{ md: 960 }}
<Stack >
direction="row" <Stack width={{ md: 0.5 }} m={2.5} gap={10}>
bgcolor="background.paper" <Link href="/" height="fit-content">
boxShadow={(theme) => theme.shadows[3]} <Image src={logo} width={82.6} />
height={560} </Link>
width={{ md: 960 }} <Stack alignItems="center" gap={2.5} width={330} mx="auto">
> <Typography variant="h3">Login</Typography>
<Stack width={{ md: 0.5 }} m={2.5} gap={10}> <Formik
<Link href="/" height="fit-content"> initialValues={{
<Image src={logo} width={82.6} /> email: '',
</Link> password: '',
<Stack alignItems="center" gap={2.5} width={330} mx="auto"> }}
<Typography variant="h3">Login</Typography> onSubmit={handleLogin}
<Formik >
initialValues={{ {({ setFieldValue }) => (
email: '', <Form>
password: '', <FormControl variant="standard" fullWidth>
}} {errorMessage ? (
onSubmit={handleLogin} <Alert severity="error">
> {errorMessage.detail ? (
{({setFieldValue}) => ( <Typography>{errorMessage.detail}</Typography>
) : (
<Form> <ul>
<FormControl variant="standard" fullWidth> {Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
{errorMessage ? ( <li key={fieldName}>
<Alert severity='error'> <strong>{fieldName}</strong>
<ul> {Array.isArray(errorMessages) ? (
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => ( <ul>
<li key={fieldName}> {errorMessages.map((message, index) => (
<strong>{fieldName}</strong> <li key={`${fieldName}-${index}`}>{message}</li>
{errorMessages.length > 0 ? ( ))}
<ul> </ul>
{errorMessages.map((message, index) => ( ) : (
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message <span>: {String(errorMessages)}</span>
))} )}
</ul> </li>
) : ( ))}
<span> No specific errors for this field.</span> </ul>
)} )}
</li> </Alert>
))} ) : null}
</ul> <InputLabel shrink htmlFor="email">
Email
</Alert> </InputLabel>
<TextField
): null} variant="filled"
<InputLabel shrink htmlFor="email"> placeholder="Enter your email"
Email id="email"
</InputLabel> onChange={(event) => setFieldValue('email', event.target.value)}
<TextField InputProps={{
variant="filled" endAdornment: (
placeholder="Enter your email" <InputAdornment position="end">
id="email" <IconifyIcon icon="ic:baseline-email" />
onChange={(event) => setFieldValue('email', event.target.value)} </InputAdornment>
InputProps={{ ),
endAdornment: ( }}
<InputAdornment position="end"> />
<IconifyIcon icon="ic:baseline-email" /> </FormControl>
</InputAdornment> <FormControl variant="standard" fullWidth>
), <InputLabel shrink htmlFor="password">
}} Password
/> </InputLabel>
</FormControl> <TextField
<FormControl variant="standard" fullWidth> variant="filled"
<InputLabel shrink htmlFor="password"> placeholder="********"
Password onChange={(event) => setFieldValue('password', event.target.value)}
</InputLabel> type={showPassword ? 'text' : 'password'}
<TextField id="password"
variant="filled" InputProps={{
placeholder="********" endAdornment: (
onChange={(event) => setFieldValue('password', event.target.value)} <InputAdornment position="end">
type={showPassword ? 'text' : 'password'} <IconButton
id="password" aria-label="toggle password visibility"
InputProps={{ onClick={handleClickShowPassword}
endAdornment: ( edge="end"
<InputAdornment position="end"> sx={{
<IconButton color: 'text.secondary',
aria-label="toggle password visibility" }}
onClick={handleClickShowPassword} >
edge="end" {showPassword ? (
sx={{ <IconifyIcon icon="ic:baseline-key-off" />
color: 'text.secondary', ) : (
}} <IconifyIcon icon="ic:baseline-key" />
> )}
{showPassword ? ( </IconButton>
<IconifyIcon icon="ic:baseline-key-off" /> </InputAdornment>
) : ( ),
<IconifyIcon icon="ic:baseline-key" /> }}
)} />
</IconButton> </FormControl>
</InputAdornment> <Typography
), variant="body1"
}} sx={{
/> alignSelf: 'flex-end',
</FormControl> }}
<Typography >
variant="body1" <Link href="/authentication/forgot-password" underline="hover">
sx={{ Forget password
alignSelf: 'flex-end', </Link>
}} </Typography>
> <Button variant="contained" type={'submit'} fullWidth>
<Link href="/authentication/forgot-password" underline="hover"> Log in
Forget password </Button>
</Link> </Form>
</Typography> )}
<Button variant="contained" type={'submit'} fullWidth> </Formik>
Log in <Typography variant="body2" color="text.secondary">
</Button> Don't have an account ?{' '}
</Form> <Link
)} href="/authentication/sign-up"
</Formik> underline="hover"
<Typography variant="body2" color="text.secondary"> fontSize={(theme) => theme.typography.body1.fontSize}
Don't have an account ?{' '} >
<Link Sign up
href="/authentication/sign-up" </Link>
underline="hover" </Typography>
fontSize={(theme) => theme.typography.body1.fontSize} </Stack>
> </Stack>
Sign up <Suspense
</Link> fallback={
</Typography> <Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
</Stack> }
</Stack> >
<Suspense <Image
fallback={ alt="Login banner"
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} /> src={loginBanner}
} sx={{
> width: 0.5,
<Image display: { xs: 'none', md: 'block' },
alt="Login banner" }}
src={loginBanner} />
sx={{ </Suspense>
width: 0.5, </Stack>
display: { xs: 'none', md: 'block' }, );
}} };
/>
</Suspense> export default Login;
</Stack>
);
};
export default Login;

View File

@@ -1,166 +1,170 @@
import { ReactElement, Suspense, useState } from 'react'; import { ReactElement, Suspense, useState } from 'react';
import { import {
Button, Button,
FormControl, FormControl,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputLabel, InputLabel,
Link, Link,
Skeleton, Skeleton,
Stack, Stack,
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import logo from 'assets/logo/favicon-logo.png'; import logo from 'assets/logo/favicon-logo.png';
import resetPassword from 'assets/authentication-banners/green.png'; import resetPassword from 'assets/authentication-banners/green.png';
import passwordUpdated from 'assets/authentication-banners/password-updated.png'; import passwordUpdated from 'assets/authentication-banners/password-updated.png';
import successTick from 'assets/authentication-banners/successTick.png'; import successTick from 'assets/authentication-banners/successTick.png';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import IconifyIcon from 'components/base/IconifyIcon'; import IconifyIcon from 'components/base/IconifyIcon';
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
const ResetPassword = (): ReactElement => {
const [showNewPassword, setShowNewPassword] = useState(false); const ResetPassword = (): ReactElement => {
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword); const [password, setPassword] = useState('');
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
const [resetSuccessful, setResetSuccessful] = useState(false); const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
const handleResetPassword = () => { const [resetSuccessful, setResetSuccessful] = useState(false);
const passwordField: HTMLInputElement = document.getElementById(
'new-password', const handleResetPassword = () => {
) as HTMLInputElement; const passwordField: HTMLInputElement = document.getElementById(
const confirmPasswordField: HTMLInputElement = document.getElementById( 'new-password',
'confirm-password', ) as HTMLInputElement;
) as HTMLInputElement; const confirmPasswordField: HTMLInputElement = document.getElementById(
'confirm-password',
if (passwordField.value !== confirmPasswordField.value) { ) as HTMLInputElement;
alert("Passwords don't match");
return; if (passwordField.value !== confirmPasswordField.value) {
} alert("Passwords don't match");
setResetSuccessful(true); return;
}; }
setResetSuccessful(true);
return ( };
<Stack
direction="row" return (
bgcolor="background.paper" <Stack
boxShadow={(theme) => theme.shadows[3]} direction="row"
height={560} bgcolor="background.paper"
width={{ md: 960 }} boxShadow={(theme) => theme.shadows[3]}
> height={560}
<Stack width={{ md: 0.5 }} m={2.5} gap={10}> width={{ md: 960 }}
<Link href="/" width="fit-content"> >
<Image src={logo} width={82.6} /> <Stack width={{ md: 0.5 }} m={2.5} gap={10}>
</Link> <Link href="/" width="fit-content">
{!resetSuccessful ? ( <Image src={logo} width={82.6} />
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> </Link>
<Typography variant="h3">Reset Password</Typography> {!resetSuccessful ? (
<FormControl variant="standard" fullWidth> <Stack alignItems="center" gap={3.75} width={330} mx="auto">
<InputLabel shrink htmlFor="new-password"> <Typography variant="h3">Reset Password</Typography>
Password <FormControl variant="standard" fullWidth>
</InputLabel> <InputLabel shrink htmlFor="new-password">
<TextField Password
variant="filled" </InputLabel>
placeholder="Enter new password" <TextField
type={showNewPassword ? 'text' : 'password'} variant="filled"
id="new-password" placeholder="Enter new password"
InputProps={{ type={showNewPassword ? 'text' : 'password'}
endAdornment: ( id="new-password"
<InputAdornment position="end"> onChange={(e) => setPassword(e.target.value)}
<IconButton InputProps={{
aria-label="toggle password visibility" endAdornment: (
onClick={handleClickShowNewPassword} <InputAdornment position="end">
edge="end" <IconButton
sx={{ aria-label="toggle password visibility"
color: 'text.secondary', onClick={handleClickShowNewPassword}
}} edge="end"
> sx={{
{showNewPassword ? ( color: 'text.secondary',
<IconifyIcon icon="ic:baseline-key-off" /> }}
) : ( >
<IconifyIcon icon="ic:baseline-key" /> {showNewPassword ? (
)} <IconifyIcon icon="ic:baseline-key-off" />
</IconButton> ) : (
</InputAdornment> <IconifyIcon icon="ic:baseline-key" />
), )}
}} </IconButton>
/> </InputAdornment>
</FormControl> ),
<FormControl variant="standard" fullWidth> }}
<InputLabel shrink htmlFor="confirm-password"> />
Password <PasswordStrengthChecker password={password} />
</InputLabel> </FormControl>
<TextField <FormControl variant="standard" fullWidth>
variant="filled" <InputLabel shrink htmlFor="confirm-password">
placeholder="Confirm password" Password
type={showConfirmPassword ? 'text' : 'password'} </InputLabel>
id="confirm-password" <TextField
InputProps={{ variant="filled"
endAdornment: ( placeholder="Confirm password"
<InputAdornment position="end"> type={showConfirmPassword ? 'text' : 'password'}
<IconButton id="confirm-password"
aria-label="toggle password visibility" InputProps={{
onClick={handleClickShowConfirmPassword} endAdornment: (
edge="end" <InputAdornment position="end">
sx={{ <IconButton
color: 'text.secondary', aria-label="toggle password visibility"
}} onClick={handleClickShowConfirmPassword}
> edge="end"
{showConfirmPassword ? ( sx={{
<IconifyIcon icon="ic:baseline-key-off" /> color: 'text.secondary',
) : ( }}
<IconifyIcon icon="ic:baseline-key" /> >
)} {showConfirmPassword ? (
</IconButton> <IconifyIcon icon="ic:baseline-key-off" />
</InputAdornment> ) : (
), <IconifyIcon icon="ic:baseline-key" />
}} )}
/> </IconButton>
</FormControl> </InputAdornment>
<Button variant="contained" fullWidth onClick={handleResetPassword}> ),
Reset Password }}
</Button> />
<Typography variant="body2" color="text.secondary"> </FormControl>
Back to{' '} <Button variant="contained" fullWidth onClick={handleResetPassword}>
<Link Reset Password
href="/authentication/login" </Button>
underline="hover" <Typography variant="body2" color="text.secondary">
fontSize={(theme) => theme.typography.body1.fontSize} Back to{' '}
> <Link
Log in href="/authentication/login"
</Link> underline="hover"
</Typography> fontSize={(theme) => theme.typography.body1.fontSize}
</Stack> >
) : ( Log in
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> </Link>
<Image src={successTick} /> </Typography>
<Typography variant="h3">Reset Successfully</Typography> </Stack>
<Typography variant="body1" textAlign="center" color="text.secndary"> ) : (
Your Ditch the Agent log in password has been updated successfully <Stack alignItems="center" gap={3.75} width={330} mx="auto">
</Typography> <Image src={successTick} />
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login"> <Typography variant="h3">Reset Successfully</Typography>
Continue to Login <Typography variant="body1" textAlign="center" color="text.secndary">
</Button> Your Ditch the Agent log in password has been updated successfully
</Stack> </Typography>
)} <Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">
</Stack> Continue to Login
<Suspense </Button>
fallback={ </Stack>
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} /> )}
} </Stack>
> <Suspense
<Image fallback={
alt={resetSuccessful ? 'Reset done' : 'Login banner'} <Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
src={resetSuccessful ? passwordUpdated : resetPassword} }
sx={{ >
width: 0.5, <Image
display: { xs: 'none', md: 'block' }, alt={resetSuccessful ? 'Reset done' : 'Login banner'}
}} src={resetSuccessful ? passwordUpdated : resetPassword}
/> sx={{
</Suspense> width: 0.5,
</Stack> display: { xs: 'none', md: 'block' },
); }}
}; />
</Suspense>
export default ResetPassword; </Stack>
);
};
export default ResetPassword;

View File

@@ -22,6 +22,7 @@ import IconifyIcon from 'components/base/IconifyIcon';
import logo from 'assets/logo/favicon-logo.png'; import logo from 'assets/logo/favicon-logo.png';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import { axiosInstance } from '../../axiosApi.js'; import { axiosInstance } from '../../axiosApi.js';
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
type SignUpValues = { type SignUpValues = {
@@ -36,6 +37,7 @@ type SignUpValues = {
const SignUp = (): ReactElement => { const SignUp = (): ReactElement => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false); const [showPassword2, setShowPassword2] = useState(false);
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<any | null>(null); const [errorMessage, setErrorMessage] = useState<any | null>(null);
@@ -217,7 +219,10 @@ const SignUp = (): ReactElement => {
<TextField <TextField
variant="filled" variant="filled"
placeholder="********" placeholder="********"
onChange={(event) => setFieldValue('password', event.target.value)} onChange={(event) => {
setFieldValue('password', event.target.value);
setPassword(event.target.value);
}}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
id="password" id="password"
InputProps={{ InputProps={{
@@ -241,6 +246,7 @@ const SignUp = (): ReactElement => {
), ),
}} }}
/> />
<PasswordStrengthChecker password={password} />
</FormControl> </FormControl>
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="password"> <InputLabel shrink htmlFor="password">

View File

@@ -1,5 +1,6 @@
export const rootPaths = { export const rootPaths = {
homeRoot: '', homeRoot: '',
dashboardRoot: 'dashboard',
pagesRoot: 'pages', pagesRoot: 'pages',
applicationsRoot: 'applications', applicationsRoot: 'applications',
ecommerceRoot: 'ecommerce', ecommerceRoot: 'ecommerce',
@@ -16,12 +17,17 @@ export const rootPaths = {
profileRoot: 'profile', profileRoot: 'profile',
offersRoot: 'offers', offersRoot: 'offers',
bidsRoot: 'bids', bidsRoot: 'bids',
documentsRoot: 'documents',
vendorBidsRoot: 'vendor-bids', vendorBidsRoot: 'vendor-bids',
upgradeRoot: 'upgrade', upgradeRoot: 'upgrade',
propertySearchRoot: 'property-search',
supportRoot: 'support',
publicRoot: 'public',
}; };
export default { export default {
home: `/${rootPaths.homeRoot}`, home: `/${rootPaths.homeRoot}`,
dashboard: `/${rootPaths.dashboardRoot}`,
login: `/${rootPaths.authRoot}/login`, login: `/${rootPaths.authRoot}/login`,
signup: `/${rootPaths.authRoot}/sign-up`, signup: `/${rootPaths.authRoot}/sign-up`,
resetPassword: `/${rootPaths.authRoot}/reset-password`, resetPassword: `/${rootPaths.authRoot}/reset-password`,
@@ -31,7 +37,9 @@ export default {
educationLesson: `/${rootPaths.educationRoot}/lesson`, educationLesson: `/${rootPaths.educationRoot}/lesson`,
property: `/${rootPaths.propertyRoot}`, property: `/${rootPaths.propertyRoot}`,
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`, propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
propertySearch: `/${rootPaths.propertyRoot}/search`, propertySearch: `/${rootPaths.propertySearchRoot}`,
publicPropertySearch: `/${rootPaths.homeRoot}`,
publicPropertyDetail: `/${rootPaths.publicRoot}/:propertyId`,
vendors: `/${rootPaths.vendorsRoot}`, vendors: `/${rootPaths.vendorsRoot}`,
termsOfService: `/${rootPaths.termsOfServiceRoot}`, termsOfService: `/${rootPaths.termsOfServiceRoot}`,
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`, mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
@@ -43,6 +51,9 @@ export default {
bids: `/${rootPaths.bidsRoot}/`, bids: `/${rootPaths.bidsRoot}/`,
vendorBids: `/${rootPaths.vendorBidsRoot}/`, vendorBids: `/${rootPaths.vendorBidsRoot}/`,
upgrade: `/${rootPaths.upgradeRoot}/`, upgrade: `/${rootPaths.upgradeRoot}/`,
documents: `/${rootPaths.documentsRoot}/`,
support: `/${rootPaths.supportRoot}/`,
supportManager: `/${rootPaths.supportRoot}/manager/`,
// need to do these pages // need to do these pages
profile: `/${rootPaths.profileRoot}/`, profile: `/${rootPaths.profileRoot}/`,

View File

@@ -29,9 +29,25 @@ import ProfilePage from 'pages/Profile/Profile';
import Dashboard from 'pages/home/Dashboard'; import Dashboard from 'pages/home/Dashboard';
import PropertyDetailPage from 'pages/Property/PropertyDetailPage'; import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
import PropertySearchPage from 'pages/Property/PropertySearchPage'; import PropertySearchPage from 'pages/Property/PropertySearchPage';
import PublicPropertySearch from 'pages/Property/PublicPropertySearch';
import BidsPage from 'pages/Bids/Bids'; import BidsPage from 'pages/Bids/Bids';
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids'; import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
import UpgradePage from 'pages/Upgrade/UpgradePage'; 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 App = lazy(() => import('App'));
const MainLayout = lazy(async () => { const MainLayout = lazy(async () => {
@@ -47,6 +63,13 @@ const AuthLayout = lazy(async () => {
]).then(([moduleExports]) => moduleExports); ]).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 () => { const Error404 = lazy(async () => {
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
return import('pages/errors/Error404'); return import('pages/errors/Error404');
@@ -92,7 +115,7 @@ const routes: RouteObject[] = [
), ),
children: [ children: [
{ {
path: rootPaths.homeRoot, path: rootPaths.dashboardRoot,
element: ( element: (
<ProtectedRoute> <ProtectedRoute>
<MainLayout> <MainLayout>
@@ -104,9 +127,41 @@ const routes: RouteObject[] = [
), ),
children: [ children: [
{ {
path: paths.home, path: paths.dashboard,
element: <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, path: paths.propertyDetail,
element: <PropertyDetailPage />, element: <PropertyDetailPage />,
}, },
],
},
{
path: rootPaths.propertySearchRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{ {
path: paths.propertySearch, path: paths.propertySearch,
element: <PropertySearchPage />, 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, 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, path: rootPaths.profileRoot,

View File

@@ -1,5 +1,9 @@
// src/templates/types.ts // 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 { export interface NavItem {
title: string; title: string;
path: string; path: string;
@@ -105,6 +109,14 @@ export interface PropertyOwnerAPI {
phone_number: string; 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 { export interface VendorAPI {
user: UserAPI; user: UserAPI;
business_name: string; business_name: string;
@@ -222,10 +234,6 @@ export interface TaxHistoryAPI {
year: number; year: number;
} }
export interface OpenHouseAPI {
lsited_date: string;
}
export interface SchoolAPI { export interface SchoolAPI {
id?: number; id?: number;
address: string; address: string;
@@ -297,6 +305,7 @@ export interface PropertiesAPI {
tax_info: TaxHistoryAPI; tax_info: TaxHistoryAPI;
open_houses?: OpenHouseAPI[]; open_houses?: OpenHouseAPI[];
schools: SchoolAPI[]; schools: SchoolAPI[];
documents?: DocumentAPI[];
} }
export interface BidImageAPI { export interface BidImageAPI {
@@ -327,6 +336,19 @@ export interface BidAPI {
updated_at: string; 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'> { export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
owner: number; owner: number;
} }
@@ -711,4 +733,37 @@ export interface PropertyResponseAPI {
compsLookupExecutionTimeMS: string; 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 // Walk Score API Type Definitions

View File

@@ -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 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);
};