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"
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

@@ -6,6 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:beta": "tsc && vite build --mode beta",
"build:prod": "tsc && vite build --mode production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
@@ -14,15 +16,20 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/material": "^5.15.14",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^7.2.0",
"@mui/x-data-grid-generator": "^7.2.0",
"@mui/x-date-pickers": "^8.11.2",
"@react-google-maps/api": "^2.20.7",
"@types/zxcvbn": "^4.4.5",
"@vis.gl/react-google-maps": "^1.5.4",
"axios": "^1.10.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"echarts-for-react": "^3.0.2",
"eslint-config-prettier": "^10.1.8",
"formik": "^2.4.6",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
@@ -31,7 +38,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"simplebar-react": "^3.2.5"
"simplebar-react": "^3.2.5",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@iconify/react": "^4.1.1",

View File

@@ -1,14 +1,14 @@
import axios from 'axios';
import Cookies from 'js-cookie';
const baseURL = 'http://127.0.0.1:8010/api/';
//const baseURL = 'https://backend.ditchtheagent.com/api/';
const baseURL = import.meta.env.VITE_API_URL;
console.log(baseURL);
export const axiosRealEstateApi = axios.create({
baseURL: 'https://api.realestateapi.com/v2/',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70',
'X-API-Key': import.meta.env.REAL_ESTATE_API_KEY,
'X-User-Id': 'UniqueUserIdentifier',
},
});

View File

@@ -3,7 +3,6 @@ import React, { ReactNode } from 'react';
import { Grid } from '@mui/material';
import { GenericCategory } from 'types';
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
categories: TCategory[];
onSelectCategory: (categoryId: string) => void;
@@ -18,7 +17,7 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
return (
<Grid container spacing={3}>
{categories.map((category) => (
<Grid item xs={12} sm={6} md={4} key={category.id}>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={category.id}>
{renderCategoryCard(category, onSelectCategory)}
</Grid>
))}

View File

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

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

@@ -4,10 +4,23 @@ import { ReactElement} from 'react';
const LoadingSkeleton = (): ReactElement => {
return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
<Container
maxWidth="lg"
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
>
<Paper
elevation={3}
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
>
<Grid container sx={{ height: '100%' }}>
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
<Grid
size={{ xs: 12, sm: 6, md: 4 }}
sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
@@ -16,10 +29,8 @@ const LoadingSkeleton = (): ReactElement => {
</Grid>
</Grid>
</Paper>
</Container>
)
}
);
};
export default LoadingSkeleton;

View File

@@ -30,7 +30,7 @@ const VendorBidsPage: React.FC = () => {
</Typography>
<Grid container spacing={3} sx={{ mt: 3 }}>
{bids.map((bid) => (
<Grid item xs={12} md={6} lg={4} key={bid.id}>
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={bid.id}>
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
</Grid>
))}

View File

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

View File

@@ -1,11 +1,12 @@
import { AxiosResponse } from 'axios';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, UserAPI } from 'types';
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
import { axiosInstance } from '../../../../../axiosApi';
import Grid from '@mui/material/Unstable_Grid2';
//==import Grid from '@mui/material/Unstable_Grid2';
import {
Alert,
Button,
Grid,
Card,
CardActionArea,
CardContent,
@@ -26,6 +27,7 @@ import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { GridColDef } from '@mui/x-data-grid';
import PropertyDetailCard from '../Property/PropertyDetailCard';
import { DashboardProps } from 'pages/home/Dashboard';
import SavedPropertiesTable from './SavedPropertiesTable';
interface Row {
id: number;
@@ -35,6 +37,9 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
const [numBids, setNumBids] = useState<Number>(0);
const [numOffers, setNumOffers] = useState<Number>(0);
const [savedProperties, setSavedProperties] = useState<PropertiesAPI[]>([]);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
const navigate = useNavigate();
useEffect(() => {
@@ -69,16 +74,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
console.log(error);
}
};
const fetchSavedProperties = async () => {
try {
let expandedSavedProperties: PropertiesAPI[] = [];
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
await axiosInstance.get('/saved-properties/');
const requests = data.map((item) =>
axiosInstance.get(`/properties/${item.property}/?search=1`),
);
const responses = await Promise.all(requests);
expandedSavedProperties = responses.map((response) => response.data);
console.log(expandedSavedProperties);
setSavedProperties(expandedSavedProperties);
} catch (error) {
console.log(error);
}
};
const fetchDocuments = async () => {
try {
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
console.log('documents', data);
setDocuments(data);
} catch (error) {
console.log(error);
}
};
fetchProperties();
fetchOffers();
fetchBids();
fetchSavedProperties();
fetchDocuments();
}, []);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
console.log('handle save. IMPLEMENT ME');
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
try {
const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`,
{
...updatedProperty,
owner: account.id,
},
);
const updatedProperties = properties.map((item) => {
if (item.id === data.id) {
return { ...item, ...data };
}
return item;
});
setProperties(updatedProperties);
setMessage({ type: 'success', text: 'Property has been updated' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleDeleteProperty = (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
const handleDeleteProperty = async (propertyId: number) => {
try {
await axiosInstance.delete(`/properties/${propertyId}/`);
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
const documentColumns: GridColDef[] = [
@@ -122,6 +183,8 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const numSaves = properties.reduce((accum, currProperty) => {
return accum + currProperty.saves;
}, 0);
const savedPropertiesCardLength: number = savedProperties.length === 0 ? 6 : 12;
const documentsCardLength: number = documents.length === 0 ? 6 : 12;
return (
<Grid
@@ -201,11 +264,38 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent>
</Card>
</Grid>
{account.tier === 'basic' && (
<Grid xs={12} md={4}>
<Card>
<Stack direction="column">
<Typography variant="h4">Upgrade your account</Typography>
<Typography variant="caption">
Unlock premium features to get more features and sell faster
</Typography>
</Stack>
<CardActionArea>
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
</Grid>
)}
{/* Properties */}
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{properties.length > 0 && (
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
{/* Properties */}
)}
{properties.map((item) => (
<Grid xs={12} key={item.id}>
<PropertyDetailCard
@@ -221,21 +311,42 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
<Grid xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid xs={12} md={6}>
<Grid xs={12} md={documentsCardLength}>
<Card sx={{ display: 'flex' }}>
<Stack direction="column">
<Typography variant="h4">Documents Requiring Attention</Typography>
<Typography variant="caption">something</Typography>
</Stack>
{documents.length === 0 ? (
<Typography variant="caption">
There are no documents that require your attention at this point
</Typography>
) : (
<CardContent sx={{ flexGrow: 1 }}>
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
</CardContent>
)}
</Card>
</Grid>
<Grid xs={12} md={6}>
<Grid xs={12} md={savedPropertiesCardLength}>
<Card>
<Stack direction="column">
<Stack direction="column">
<Typography variant="h4">Saved Properties</Typography>
<Typography variant="caption">Keep track of the properties you have saved</Typography>
</Stack>
<CardContent>
<SavedPropertiesTable savedProperties={savedProperties} />
</CardContent>
</Stack>
</Card>
</Grid>
<Grid xs={12} md={12}>
{account.tier === 'premium' ? (
<Card>
<Stack direction="column">
<Stack direction="column">
<Typography variant="h4">Video Progress</Typography>
<Typography variant="caption">
@@ -246,55 +357,26 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
<CardContent>
<EducationInfoCards />
</CardContent>
</Stack>
</Card>
</Grid>
<Grid xs={12} md={4}>
) : (
<Card>
<Stack direction="column">
<Typography variant="h4">Upgrade your account</Typography>
<Typography variant="h4">Video Progress</Typography>
<Typography variant="caption">
Unlock premium features to get more features and sell faster
Upgrade to get access to FSBO educational videos
</Typography>
</Stack>
<CardActionArea>
<Button variant="contained" component="label">
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
Upgrade
</Button>
<Button>Learn More</Button>
</CardActionArea>
</Card>
)}
</Grid>
{/* <Grid xs={12} md={4}>
<NotificationInfoCard />
</Grid>
<Grid xs={12} md={8}>
<EducationInfoCards />
</Grid>
<Grid xs={12}>
<SaleInfoCards />
</Grid>
<Grid xs={12} md={8}>
<Revenue />
</Grid>
<Grid xs={12} md={4}>
<WebsiteVisitors />
</Grid>
<Grid xs={12} lg={8}>
<TopSellingProduct />
</Grid>
<Grid xs={12} lg={4}>
<Stack
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
gap={3.75}
height={1}
width={1}
>
<NewCustomers />
<BuyersProfile />
</Stack>
</Grid> */}
</Grid>
);
};

View File

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

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

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 (
<Grid container spacing={3}>
{categories.map((category) => (
<Grid item xs={12} sm={6} md={4} key={category.name}>
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={category.name}>
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
</Grid>
))}

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 { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material';
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
import { useNavigate } from 'react-router-dom'
import { renderProgress } from '@mui/x-data-grid-generator';
import { GridColDef } from '@mui/x-data-grid';
import { axiosInstance } from '../../../../../axiosApi';
import { VideoProgressAPI } from 'types';
type EducationInfoProps = {
title: string;
}
interface 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'} />
</Stack>
)
interface CategoryProgress {
categoryName: string;
totalProgress: number;
videoCount: number;
averageProgress: number;
}
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
interface EducationInfoCardProps {
category: string;
progress: number;
totalVideos: number;
completedVideos: number;
}
const EducationInfoCard = ({
category,
progress,
totalVideos,
completedVideos,
}: EducationInfoCardCardProps): ReactElement => {
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}
<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>
<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>
)
);
};
export const EducationInfoCards = () => {
const [videoProgressData, setVideoProgressData] = useState<VideoProgressAPI[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// This is a mock function. Replace with your actual API call.
const fetchVideoProgress = async (): Promise<VideoProgressAPI[]> => {
try {
const { data } = await axiosInstance.get(`/videos/progress/`);
return data;
} catch (error) {
return [];
}
};
const loadData = async () => {
try {
setLoading(true);
const data = await fetchVideoProgress();
setVideoProgressData(data);
} catch (err) {
setError('Failed to fetch video progress data.');
console.error(err);
} finally {
setLoading(false);
}
};
loadData();
}, []);
const getCategoryProgress = () => {
if (!videoProgressData) return {};
const categories: { [key: string]: CategoryProgress } = {};
videoProgressData.forEach((item) => {
// Access the category name from the nested object
const categoryName = item.video.category?.name;
if (!categoryName) return; // Skip items without a category name
if (!categories[categoryName]) {
categories[categoryName] = {
categoryName: categoryName,
totalProgress: 0,
videoCount: 0,
averageProgress: 0,
};
}
categories[categoryName].totalProgress += item.progress;
categories[categoryName].videoCount++;
});
// Calculate average progress for each category
for (const key in categories) {
const categoryInfo = categories[key];
categoryInfo.averageProgress = Math.round(
categoryInfo.totalProgress / categoryInfo.videoCount,
);
}
export default EducationInfo;
return categories;
};
const categoryProgressData = getCategoryProgress();
if (loading) {
return <Typography>Loading progress...</Typography>;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (videoProgressData.length === 0) {
return <Typography>There are no videos yet</Typography>;
} else {
return (
<Stack
direction={{ sm: 'row' }}
justifyContent={{ sm: 'space-between' }}
gap={3.75}
flexWrap="wrap"
>
{Object.values(categoryProgressData).map((data, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<EducationInfoCard
category={data.categoryName}
progress={data.averageProgress}
totalVideos={data.videoCount}
completedVideos={
videoProgressData.filter(
(item) =>
item.video.category?.name === data.categoryName && item.progress === 100,
).length
}
/>
</Grid>
))}
</Stack>
);
}
};

View File

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

View File

@@ -20,21 +20,33 @@ interface VideoProgress {
progress: number;
}
interface VideoPlayerProps {
video: VideoItem;
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video, updateVideoItem }) => {
const { account, accountLoading } = useContext(AccountContext);
if (!video || accountLoading) {
return (
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h6" color="text.secondary">No video selected</Typography>
<Paper
elevation={2}
sx={{
p: 3,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="h6" color="text.secondary">
No video selected
</Typography>
</Paper>
);
}
//
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
@@ -47,22 +59,24 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Function to save progress to the backend
const saveProgress = useCallback(async (time: number, completed: boolean = false) => {
const saveProgress = useCallback(
async (time: number, completed: boolean = false) => {
if (!videoRef.current) return;
const progressData: VideoProgress = {
progress: Math.round(time),
};
try {
// First, try to fetch existing progress
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`);
const response = await axiosInstance.get(
`/videos/progress/?user=${account?.id}&video=${video.id}`,
);
if (response.data.length > 0) {
// If progress exists, update it
const existingProgress = response.data[0];
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
await updateVideoItem(time, completed, progressData.progress, video.id);
} else {
// If no progress, create a new one
await axiosInstance.post('/videos/progress/', progressData);
@@ -77,7 +91,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
console.error('Failed to save video progress:', err);
setError('Failed to save video progress.');
}
}, [video.id, account?.id]);
},
[video.id, account?.id],
);
// Fetch initial progress when video changes or component mounts
useEffect(() => {
@@ -85,14 +101,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
setIsLoading(true);
setError(null);
try {
const {data,} = await axiosInstance.get<VideoProgressAPI>(`/videos/progress/${video.id}/?user=${account?.id}`);
const { data } = await axiosInstance.get<VideoProgressAPI>(
`/videos/progress/${video.id}/?user=${account?.id}`,
);
if (data) {
const progress: VideoProgress = {
current_time: data.progress,
progress: data.progress,
}
};
setCurrentTime(progress?.current_time || 0);
if (videoRef.current) {
@@ -128,7 +145,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
};
}, [video.id, account?.id, saveProgress]);
// Video event handlers
const handleTimeUpdate = () => {
if (videoRef.current) {
@@ -148,7 +164,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
const handlePlay = () => {
setIsPlaying(true);
if (videoRef.current) {
videoRef.current.play().catch(e => console.error("Error playing video:", e));
videoRef.current.play().catch((e) => console.error('Error playing video:', e));
}
};
@@ -170,7 +186,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
// Attempt to play if it was playing before, or if it's the first load
if (videoRef.current && currentTime > 0) {
videoRef.current.currentTime = currentTime;
videoRef.current.play().catch(e => console.error("Error resuming video:", e));
videoRef.current.play().catch((e) => console.error('Error resuming video:', e));
setIsPlaying(true);
}
};
@@ -195,7 +211,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
if (!videoRef.current) return;
if (!document.fullscreenElement) {
videoRef.current.requestFullscreen().catch(err => {
videoRef.current.requestFullscreen().catch((err) => {
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
});
} else {
@@ -227,12 +243,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
setSnackbarOpen(false);
};
return (
<Paper elevation={3} sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>{video.name}</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>{video.description}</Typography>
<Box sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}>
<Typography variant="h6" gutterBottom>
{video.name}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{video.description}
</Typography>
<Box
sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}
>
<video
ref={videoRef}
src={video.videoUrl}
@@ -250,12 +271,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
backgroundColor: '#000',
borderRadius: 2,
}}
>
<Typography>
Your browser does nto support the video tag.
</Typography>
<Typography>Your browser does nto support the video tag.</Typography>
</video>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
@@ -276,12 +293,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
</Box>
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
<IconButton onClick={toggleFullScreen} color="primary" size="large">
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />}
{isFullScreen ? (
<ExitFullscreenIcon fontSize="inherit" />
) : (
<FullscreenIcon fontSize="inherit" />
)}
</IconButton>
</Tooltip>
</Box>
<Typography variant="body2" sx={{ mt: 2 }}>
Current Progress: {Math.round(video.progress/video.duration*100)}% - Status: {video.status}
Current Progress: {Math.round((video.progress / video.duration) * 100)}% - Status:{' '}
{video.status}
</Typography>
</Paper>
);

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

View File

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

View File

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

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 { Card, CardContent, Typography, TextField, Button, Box, Alert } from '@mui/material';
import { Card, CardContent, Typography, TextField, Button, Alert, Tooltip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
interface OfferSubmissionCardProps {
onOfferSubmit: (offerAmount: number) => void;
onOfferSubmit: (
offerAmount: number,
closing_days: number,
contingencies: string,
) => Promise<{ status: number; message?: string }>;
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
listingPrice: number;
existingOffer?: {
document_id: string;
};
}
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
onOfferSubmit,
listingStatus,
listingPrice,
existingOffer,
}) => {
const [offerAmount, setOfferAmount] = useState<string>('');
const [closingDuration, setClosingDuration] = useState<string>('');
const [contingencies, setContingencies] = useState<string>('None');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const getClosingDate = () => {
if (closingDuration) {
const days = parseInt(closingDuration, 10);
if (!isNaN(days)) {
const closingDate = new Date();
closingDate.setDate(closingDate.getDate() + days);
return closingDate.toLocaleDateString();
}
}
return '';
};
const offerPercentage =
offerAmount && listingPrice ? (parseFloat(offerAmount) / listingPrice) * 100 : 0;
if (existingOffer) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Offer Already Submitted
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
You have already submitted an offer for this property.
</Typography>
<Button
variant="contained"
color="primary"
fullWidth
onClick={() => navigate(`/documents?selectedDocument=${existingOffer.document_id}`)}
>
View Offer
</Button>
</CardContent>
</Card>
);
}
if (listingStatus === 'active') {
const handleSubmit = () => {
const handleSubmit = async () => {
const amount = parseFloat(offerAmount);
if (amount > 0) {
onOfferSubmit(amount);
const closing_days = parseFloat(closingDuration);
if (amount > 0 && closing_days) {
try {
const response = await onOfferSubmit(amount, closing_days, contingencies);
if (response.status === 200 || response.status === 201) {
setSubmitted(true);
setError(null);
setTimeout(() => setSubmitted(false), 5000);
} else {
setError(response.message || 'An unknown error occurred.');
setSubmitted(false);
setTimeout(() => setError(null), 5000);
}
} catch (err: any) {
setError(err.message || 'Failed to submit offer.');
setSubmitted(false);
setTimeout(() => setError(null), 5000);
}
}
};
const isButtonDisabled = !offerAmount || !closingDuration;
return (
<Card>
<CardContent>
@@ -36,8 +105,38 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
value={offerAmount}
onChange={(e) => setOfferAmount(e.target.value)}
sx={{ mb: 2 }}
helperText={
offerPercentage > 0
? `This offer is ${offerPercentage.toFixed(2)}% of the listing price.`
: ''
}
/>
<Button variant="contained" color="primary" fullWidth onClick={handleSubmit}>
<TextField
fullWidth
label="Closing Duration (days)"
type="number"
value={closingDuration}
onChange={(e) => setClosingDuration(e.target.value)}
sx={{ mb: 2 }}
helperText={closingDuration ? `Estimated closing date: ${getClosingDate()}` : ''}
/>
<Tooltip title="Typical contingencies include financing, inspection, and appraisal.">
<TextField
fullWidth
label="Contingencies"
type="text"
value={contingencies}
onChange={(e) => setContingencies(e.target.value)}
sx={{ mb: 2 }}
/>
</Tooltip>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleSubmit}
disabled={isButtonDisabled}
>
Submit Offer
</Button>
{submitted && (
@@ -45,6 +144,11 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
Your offer of ${offerAmount} has been submitted!
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</CardContent>
</Card>
);

View File

@@ -1,7 +1,15 @@
import React from 'react';
import { Card, CardContent, Typography, List, ListItem, ListItemText, Divider } from '@mui/material';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import { OpenHouseAPI } from 'types';
import { format } from 'date-fns';
interface OpenHouseCardProps {
openHouses: OpenHouseAPI[] | undefined;
@@ -21,8 +29,10 @@ const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
<React.Fragment key={index}>
<ListItem>
<ListItemText
primary={`${openHouse.date} at ${openHouse.time}`}
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`}
primary={`${format(new Date(openHouse.listed_date), 'MMM d, yyyy')} at ${format(
new Date(`1970-01-01T${openHouse.start_time}`),
'h a',
)} - ${format(new Date(`1970-01-01T${openHouse.end_time}`), 'h a')}`}
/>
</ListItem>
{index < openHouses.length - 1 && <Divider component="li" />}
@@ -37,7 +47,6 @@ const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
</CardContent>
</Card>
);
} else {
return (
<Card>
@@ -52,7 +61,6 @@ const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
</Card>
);
}
};
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;
onUpgrade: () => void;
onSave: (updatedUser: UserAPI) => void;
setMessage: (
value: React.SetStateAction<{
type: 'success' | 'error';
text: string;
} | null>,
) => void;
}
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) => {
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave, setMessage }) => {
const [isEditing, setIsEditing] = useState(false);
const [editedUser, setEditedUser] = useState<UserAPI>(user);
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
@@ -39,6 +45,7 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
console.log(editedUser);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (isEditing) {
const { name, value, type, checked } = e.target;
setEditedUser((prev) => ({
...prev,
@@ -52,6 +59,10 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
return newErrors;
});
}
} else {
setMessage({ type: 'error', text: 'Enable editing in the top right' });
setTimeout(() => setMessage(null), 3000);
}
};
const validateForm = () => {
@@ -101,31 +112,29 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
)}
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
name="first_name"
value={editedUser.first_name}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.first_name}
helperText={formErrors.first_name}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
name="last_name"
value={editedUser.last_name}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.last_name}
helperText={formErrors.last_name}
/>
</Grid>
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label="Email Address"
@@ -133,12 +142,11 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
type="email"
value={editedUser.email}
onChange={handleChange}
disabled={!isEditing}
error={!!formErrors.email}
helperText={formErrors.email}
/>
</Grid>
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle1">
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
</Typography>
@@ -148,7 +156,7 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
</Button>
)}
</Grid>
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle1">Notification Settings:</Typography>
{/* Example Checkboxes - You'd manage these with state too */}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>

View File

@@ -23,7 +23,7 @@ const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
// In a real app, you'd geocode the address to get these.
const demoLat = 34.0522;
const demoLng = -118.2437; // Example: Los Angeles coordinates
console.log(property)
console.log(property);
return (
<Card sx={{ mt: 3, p: 2 }}>
@@ -32,9 +32,13 @@ const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
{property.address}, {property.city}, {property.state} {property.zip_code}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Grid size={{ xs: 12, md: 6 }}>
{property.pictures && property.pictures.length > 0 && (
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}>
<ImageList
cols={property.pictures.length > 1 ? 2 : 1}
rowHeight={164}
sx={{ maxWidth: 500 }}
>
{property.pictures.map((item, index) => (
<ImageListItem key={index}>
<img
@@ -44,9 +48,7 @@ const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
alt={`Property image ${index + 1}`}
loading="lazy"
/>
<ImageListItemBar
title={`Image ${index + 1}`}
/>
<ImageListItemBar title={`Image ${index + 1}`} />
</ImageListItem>
))}
</ImageList>
@@ -58,39 +60,25 @@ const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
<Typography variant="body2" color="textSecondary">
{property.description}
</Typography>
) : (
<Button variant='contained'>
Generate Description
</Button>
<Button variant="contained">Generate Description</Button>
)}
</Grid>
<Grid item xs={12} md={6}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1">
<strong>Stats:</strong>
</Typography>
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
<Typography variant="body2">Bathrooms: {property.num_bathrooms || 'N/A'}</Typography>
<Typography variant="body2">
Sq Ft: {property.sq_ft || 'N/A'}
</Typography>
<Typography variant="body2">
Bedrooms: {property.num_bedrooms || 'N/A'}
</Typography>
<Typography variant="body2">
Bathrooms: {property.num_bathrooms || 'N/A'}
</Typography>
<Typography variant="body2">
Features: {property.features && property.features.length > 0
Features:{' '}
{property.features && property.features.length > 0
? property.features.join(', ')
: 'None'}
</Typography>
<Typography variant="body2">
Market Value: ${property.market_value || 'N/A'}
</Typography>
<Typography variant="body2">
Loan Amount: ${property.loan_amount || 'N/A'}
</Typography>
<Typography variant="body2">Market Value: ${property.market_value || 'N/A'}</Typography>
<Typography variant="body2">Loan Amount: ${property.loan_amount || 'N/A'}</Typography>
<Typography variant="body2">
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
</Typography>
@@ -98,7 +86,11 @@ const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
Loan Start Date: {property.loan_start_date || 'N/A'}
</Typography>
{property.latitude && property.longitude ? (
<MapComponent lat={property.latitude} lng={property.longitude} address={property.address} />
<MapComponent
lat={property.latitude}
lng={property.longitude}
address={property.address}
/>
) : (
<p>Error loading the map</p>
)}

View File

@@ -1,7 +1,8 @@
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
import ChangePasswordCard from './ChangePasswordCard';
import { ReactElement, useContext, useEffect, useState } from 'react';
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI } from 'types';
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI, OpenHouseAPI } from 'types';
import ProfileCard from './ProfileCard';
import { AxiosResponse } from 'axios';
import PropertyCard from './PropertyCard.';
@@ -13,12 +14,18 @@ import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
import DashboardLoading from '../Dashboard/DashboardLoading';
import { ProfileProps } from 'pages/Profile/Profile';
import { useNavigate } from 'react-router-dom';
import AddOpenHouseDialog from './AddOpenHouseDialog';
import OpenHouseDialogContent from './OpenHouseDialogContext';
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
const [loadingData, setLoadingData] = useState<boolean>(true);
const navigate = useNavigate();
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
const [openHouses, setOpenHouses] = useState<OpenHouseAPI[]>([]);
const [openAddOpenHouseDialog, setOpenAddOpenHouseDialog] = useState(false);
const [openHouseErrors, setOpenHouseErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
const fetchPropertyOwner = async () => {
try {
@@ -52,6 +59,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
console.log('setting the user to: ', data[0].owner);
setUser(data[0].owner);
}
const { data: openHousesData }: AxiosResponse<OpenHouseAPI[]> = await axiosInstance.get(
'/properties/open-houses/',
);
setOpenHouses(openHousesData);
} catch (error) {
console.log(error);
} finally {
@@ -104,6 +116,35 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
setOpenAddPropertyDialog(false);
};
const handleOpenAddOpenHouseDialog = () => {
setOpenAddOpenHouseDialog(true);
};
const handleCloseAddOpenHouseDialog = () => {
setOpenAddOpenHouseDialog(false);
setOpenHouseErrors({});
};
const handleAddOpenHouse = async (
newOpenHouseData: Omit<OpenHouseAPI, 'id'> & { listed_date: string },
) => {
try {
const { data }: AxiosResponse<OpenHouseAPI> = await axiosInstance.post(
'/properties/open-houses/',
newOpenHouseData,
);
setOpenHouses((prev) => [...prev, data]);
setMessage({ type: 'success', text: 'Open house added successfully!' });
setTimeout(() => setMessage(null), 3000);
setOpenAddOpenHouseDialog(false);
} catch (error) {
console.log(error);
setOpenHouseErrors(error.response.data);
setMessage({ type: 'error', text: 'Error adding open house.' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleAddProperty = (
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
) => {
@@ -134,29 +175,39 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
}
};
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property
console.log('Saving property: IMPLEMENT ME', updatedProperty);
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
try {
const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`,
{
...updatedProperty,
owner: account.id,
},
);
const updatedProperties = properties.map((item) => {
if (item.id === data.id) {
return { ...item, ...data };
}
return item;
});
setProperties(updatedProperties);
setMessage({ type: 'success', text: 'Property has been updated' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
const handleDeleteProperty = async (propertyId: number) => {
console.log('handle delete. IMPLEMENT ME');
try {
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete(
`/properties/${propertyId}/`,
);
console.log(data);
// remove the proprty from the list
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId));
// const indexToRemove = properties.findIndex(property => property.id === propertyId);
// console.log(indexToRemove)
// if (indexToRemove !== -1) {
// const updatedProperties = properties.splice(indexToRemove, 1)
// console.log(updatedProperties)
// setProperties(updatedProperties);
// }
} catch {
console.log('error removing');
await axiosInstance.delete(`/properties/${propertyId}/`);
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
setTimeout(() => setMessage(null), 3000);
}
};
@@ -182,8 +233,13 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
user={user.user}
onUpgrade={handleUpgradeSubscription}
onSave={handleSaveProfile}
setMessage={setMessage}
/>
<Box sx={{ mt: 4 }}>
<ChangePasswordCard />
</Box>
<Divider sx={{ my: 4 }} />
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
@@ -194,6 +250,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
Add Property
</Button>
</Box>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{properties.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
@@ -203,7 +264,7 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
) : (
<Grid container spacing={3}>
{properties.map((property) => (
<Grid item xs={12} key={property.id}>
<Grid size={{ xs: 12 }} key={property.id}>
{/* <PropertyCard property={property} /> */}
<PropertyDetailCard
property={property}
@@ -222,6 +283,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
onClose={handleCloseAddPropertyDialog}
onAddProperty={handleAddProperty}
/>
<Divider sx={{ my: 4 }} />
{properties.length > 0 ? (
<>
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
My Open Houses
</Typography>
<Button variant="contained" color="primary" onClick={handleOpenAddOpenHouseDialog}>
Add Open House
</Button>
</Box>
{openHouses.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="textSecondary">
You have no open houses scheduled.
</Typography>
</Paper>
) : (
<Grid container spacing={3}>
{openHouses.map((openHouse) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }} key={openHouse.id}>
{/* You will create a component to display the open house details */}
<OpenHouseDialogContent openHouse={openHouse} />
</Grid>
))}
</Grid>
)}
</>
) : (
<Alert severity="info" sx={{ mb: 2 }}>
Please add a property before you can schedule an open house.
</Alert>
)}
<AddOpenHouseDialog
open={openAddOpenHouseDialog}
onClose={handleCloseAddOpenHouseDialog}
onAddOpenHouse={handleAddOpenHouse}
properties={properties} // Pass the properties to the dialog
errors={openHouseErrors}
/>
</Container>
);
}

View File

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

View File

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

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

View File

@@ -7,14 +7,23 @@ import { PropertiesAPI } from 'types';
interface PropertyListItemProps {
property: PropertiesAPI;
onViewDetails: (propertyId: number) => void; // For navigation in search page
isPublic: boolean;
}
const PropertyListItem: React.FC<PropertyListItemProps> = ({ property, onViewDetails }) => {
const PropertyListItem: React.FC<PropertyListItemProps> = ({
property,
onViewDetails,
isPublic = false,
}) => {
const navigate = useNavigate();
const handleViewDetailsClick = () => {
// Navigate to the full detail page for this property
if (!isPublic) {
navigate(`/property/${property.id}/?search=1`);
} else {
navigate(`/public/${property.id}`);
}
};
const value_price = property.listed_price ? property.listed_price : property.market_value;
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';

View File

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

View File

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

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

View File

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

View File

@@ -141,8 +141,11 @@ function WebSocketProvider({ children }: WebSocketProviderProps) {
ws.current.close();
}
const wsUrl = new URL(import.meta.env.VITE_API_URL || 'ws://127.0.0.1:8010/ws/');
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
ws.current = new WebSocket(
`ws://127.0.0.1:8010/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
`${wsUrl.origin}/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
);
ws.current.onopen = () => {

View File

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

View File

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

View File

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

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

View File

@@ -29,15 +29,19 @@ const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
setNestedChecked(updatedBooleanArray);
};
const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
return (
<ListItem
sx={{
my: 1.25,
borderRadius: 2,
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
backgroundColor: { backgroundColor },
color: { color },
'&:hover': {
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
backgroundColor: { hoverBackgroundColor },
opacity: 1.5,
},
}}

View File

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

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 }}>
{bids.map((bid) => (
<Grid item xs={12} key={bid.id}>
<Grid size={{ xs: 12 }} key={bid.id}>
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
</Grid>
))}

View File

@@ -1,31 +1,85 @@
import { ReactElement, useEffect, useState } from 'react';
import {axiosInstance} from '../../axiosApi'
import { axiosInstance } from '../../axiosApi';
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
import { GenericCategory, GenericItem, VideoAPI, VideoCategory, VideoItem, VideoProgressAPI } from 'types';
import {
GenericCategory,
GenericItem,
VideoAPI,
VideoCategory,
VideoItem,
VideoProgressAPI,
} from 'types';
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
import { AxiosResponse } from 'axios';
const Education = (): ReactElement => {
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
const updateVideoCategories = (videos: VideoItem[]) => {
const categoryMap = new Map<
string,
{ name: string; total: number; completed: number; description: string; imageUrl: string }
>();
// Populate category details (you might hardcode descriptions/images or fetch them)
// For demonstration, let's assume a default image and generate a description
const defaultCategoryImages: { [key: string]: string } = {
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
// Add more as needed
};
videos.forEach((video) => {
const categoryId = video.categoryId;
if (!categoryMap.has(categoryId)) {
let categoryName = video.category;
categoryMap.set(categoryId, {
name: categoryName,
total: 0,
completed: 0,
description: `Explore ${video.category} concepts and build your skills.`,
imageUrl:
defaultCategoryImages[video.category] ||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
});
}
const categoryData = categoryMap.get(categoryId)!;
categoryData.total += 1;
if (video.status === 'completed') {
categoryData.completed += 1;
}
});
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(
([id, data]) => ({
id, // id is the category name here
name: data.name,
description: data.description,
imageUrl: data.imageUrl,
totalVideos: data.total,
completedVideos: data.completed,
categoryProgress: data.total > 0 ? (data.completed / data.total) * 100 : 0,
}),
);
setVideoCategories(processedCategories);
};
// Simulate fetching data from backend
let fetchedVideos: VideoItem[] = []
let fetchedVideos: VideoItem[] = [];
useEffect(() => {
// In a real app, you'd make an API call here
const fetchVideos = async () => {
// Replace with your actual API call
try {
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/')
const { data }: AxiosResponse<VideoProgressAPI[]> =
await axiosInstance.get('/videos/progress/');
if (data.length > 0) {
fetchedVideos = data.map(item => {
console.log(item)
fetchedVideos = data.map((item) => {
return {
id: String(item.video.id),
progress_id: item.id,
@@ -37,72 +91,42 @@ const Education = (): ReactElement => {
progress: item.progress,
videoUrl: item.video.link,
duration: item.video.duration,
}
})
setAllVideos(fetchedVideos)
}
}catch (error){
console.log('there was an error', error)
}
const categoryMap = new Map<string, {name: string; total: number; completed: number; description: string; imageUrl: string; }>();
// Populate category details (you might hardcode descriptions/images or fetch them)
// For demonstration, let's assume a default image and generate a description
const defaultCategoryImages: { [key: string]: string } = {
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
// Add more as needed
};
fetchedVideos.forEach(video => {
const categoryId = video.categoryId;
if (!categoryMap.has(categoryId)) {
let categoryName = video.category;
categoryMap.set(categoryId, {
name: categoryName,
total: 0,
completed: 0,
description: `Explore ${video.category} concepts and build your skills.`,
imageUrl: defaultCategoryImages[video.category] || 'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
});
setAllVideos(fetchedVideos);
}
const categoryData = categoryMap.get(categoryId)!;
categoryData.total += 1;
if (video.status === 'completed') {
categoryData.completed += 1;
} catch (error) {
console.log('there was an error', error);
}
});
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();
}, []);
// const handleSelectCategory = (categoryName: string) => {
// setSelectedCategory(categoryName);
// };
// const handleBackToCategories = () => {
// setSelectedCategory(null);
// };
const updateVideoItem = async (
time: number,
completed: boolean,
progress: number,
videoId: number,
) => {
const newPossibleStatus: string = completed ? 'complete' : 'in-progress';
// we already saved the data to the backend, so just update it on the Frontend
const updatedVideoItems: VideoItem[] = allVideos.map((item) => {
if (item.id === videoId) {
return {
...item,
progress: progress,
// if the video is already completed, we don't want to reset it
status: item.status === 'completed' ? item.status : newPossibleStatus,
};
}
return item;
});
updateVideoCategories(updatedVideoItems);
setAllVideos(updatedVideoItems);
};
return (
<DashboardTemplate<VideoCategory, VideoItem>
@@ -123,39 +147,22 @@ const Education = (): ReactElement => {
items={itemsInSelectedCategory}
onBack={onBack}
renderListItem={(item, isSelected, onSelect) => (
<VideoListItem video={item as VideoItem} isSelected={isSelected}
<VideoListItem
video={item as VideoItem}
isSelected={isSelected}
onSelect={() => {
console.log('selecting')
onSelect(item.id)}
} />
console.log('selecting');
onSelect(item.id);
}}
/>
)}
renderItemDetail={(item) => (
<VideoPlayer video={item as VideoItem} />
<VideoPlayer video={item as VideoItem} updateVideoItem={updateVideoItem} />
)}
/>
)}
/>
)
// (return(
// <Container maxWidth="lg" sx={{ mt: 4 }}>
// <Typography variant="h4" component="h1" gutterBottom>
// Educational Videos
// </Typography>
// {selectedCategory ? (
// <VideoPlayerPage
// categoryName={selectedCategory}
// videos={videos.filter(video => video.category === selectedCategory)}
// onBack={handleBackToCategories}
// // You'll need to pass functions for updating video progress back to Dashboard
// // For simplicity, we'll assume updates happen on the backend or in a global state
// />
// ) : (
// <CategoryGrid categories={categories} onSelectCategory={handleSelectCategory} />
// )}
// </Container>
// )
}
);
};
export default Education;

View File

@@ -1,4 +1,5 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { drawerWidth } from 'layouts/main-layout';
import {
@@ -19,6 +20,11 @@ import { axiosInstance } from '../../axiosApi';
import {
Box,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Autocomplete,
List,
Grid,
ListItem,
@@ -36,6 +42,7 @@ import { AxiosResponse } from 'axios';
import { AccountContext } from 'contexts/AccountContext';
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
import { formatTimestamp } from 'utils';
import CreateConversationDialogContent from 'components/sections/dashboard/Home/Messages/CreateConversationDialogContent';
interface Message {
id: number;
@@ -53,14 +60,38 @@ interface Conversation {
}
const Messages = (): ReactElement => {
const [searchParams] = useSearchParams();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
const [newMessageContent, setNewMessageContent] = useState<string>('');
const { account } = useContext(AccountContext);
const [openCreateConversationDialog, setOpenCreateConversationDialog] = useState(false);
const [vendors, setVendors] = useState<VendorItem[]>([]);
const [selectedVendor, setSelectedVendor] = useState<VendorItem | null>(null);
// Auto-scroll to the bottom of the messages when they update
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const selectedConversation = searchParams.get('selectedConversation');
if (selectedConversation) {
console.log(selectedConversation);
setSelectedConversationId(parseInt(selectedConversation, 10));
}
}, [searchParams]);
useEffect(() => {
const fetchVendors = async () => {
try {
const { data }: AxiosResponse<VendorItem[]> = await axiosInstance.get('/vendors/');
setVendors(data);
} catch (error) {
console.error('Failed to fetch vendors', error);
}
};
fetchVendors();
}, []);
useEffect(() => {
const fetchConversations = async () => {
try {
@@ -103,6 +134,72 @@ const Messages = (): ReactElement => {
}
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
const handleOpenCreateConversationDialog = () => {
setOpenCreateConversationDialog(true);
};
const handleCloseCreateConversationDialog = () => {
setOpenCreateConversationDialog(false);
setSelectedVendor(null);
};
const handleCreateConversation = async () => {
if (!selectedVendor) {
return;
}
try {
console.log(selectedVendor);
// first make sure that there isn't already a conversation
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
`/conversations/?vendor=${selectedVendor.user.id}`,
);
if (data.length === 0) {
const { data }: AxiosResponse<ConverationAPI> = await axiosInstance.post(
`/conversations/`,
{
property_owner: account?.id,
vendor: selectedVendor.user.id,
},
);
console.log(data);
const newConversationData = data;
console.log(newConversationData);
const lastMessageSnippet: string =
newConversationData.messages.length > 0
? newConversationData.messages[newConversationData.messages.length - 1].text
: '';
const messages: Message[] = newConversationData.messages.map((message: any) => {
return {
id: message.id,
content: message.text,
timestamp: message.timestamp,
senderId:
message.sender === newConversationData.property_owner.user.id ? 'owner' : 'vendor',
};
});
const newConversation: Conversation = {
id: newConversationData.id,
withName: selectedVendor.business_name,
lastMessageTimestamp: newConversationData.updated_at,
lastMessageSnippet: lastMessageSnippet,
messages: messages,
};
setConversations((prev) => [newConversation, ...prev]);
setSelectedConversationId(newConversation.id);
} else {
setSelectedConversationId(data[0].id);
}
handleCloseCreateConversationDialog();
} catch (error) {
console.error('Failed to create conversation', error);
}
};
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
// Handle sending a new message
@@ -147,19 +244,16 @@ const Messages = (): ReactElement => {
return (
<Container
maxWidth="lg"
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
>
<Paper
elevation={3}
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
>
<Grid container sx={{ height: '100%' }}>
<Grid container sx={{ height: '100%', width: '100%' }}>
{/* Left Panel: Conversation List */}
<Grid
item
xs={12}
md={4}
size={{ xs: 12, md: 4 }}
sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex',
@@ -172,8 +266,13 @@ const Messages = (): ReactElement => {
Conversations
</Typography>
{account?.user_type === 'property_owner' && (
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
New Conversation
<Button
variant="contained"
color="primary"
sx={{ ml: 'auto' }}
onClick={handleOpenCreateConversationDialog}
>
Create
</Button>
)}
</Stack>
@@ -234,7 +333,7 @@ const Messages = (): ReactElement => {
</Grid>
{/* Right Panel: Conversation Detail */}
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
{selectedConversation ? (
<>
{/* Conversation Header */}
@@ -355,6 +454,14 @@ const Messages = (): ReactElement => {
</Grid>
</Grid>
</Paper>
<CreateConversationDialogContent
showDialog={openCreateConversationDialog}
closeDialog={handleCloseCreateConversationDialog}
createConversation={handleCreateConversation}
vendors={vendors}
setSelectedVendor={setSelectedVendor}
selectedVendor={selectedVendor}
/>
</Container>
);
};

View File

@@ -1,15 +1,44 @@
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
import { drawerWidth } from 'layouts/main-layout';
import { ConverationAPI, ConversationItem, GenericCategory, MessagesAPI, OfferAPI, VendorCategory, VendorItem } from 'types';
import {
ConverationAPI,
ConversationItem,
GenericCategory,
MessagesAPI,
OfferAPI,
VendorCategory,
VendorItem,
} from 'types';
import DashboardTemplate from 'components/DasboardTemplate';
import CategoryGridTemplate from 'components/CategoryGridTemplate';
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import {axiosInstance} from '../../axiosApi'
import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack, Accordion, AccordionActions, AccordionSummary, AccordionDetails, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
import { axiosInstance } from '../../axiosApi';
import {
Box,
Container,
List,
Grid,
ListItem,
ListItemText,
Typography,
Paper,
TextField,
Button,
Avatar,
Stack,
Accordion,
AccordionActions,
AccordionSummary,
AccordionDetails,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
} from '@mui/material';
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
import SendIcon from '@mui/icons-material/Send';
import { AxiosResponse } from 'axios';
@@ -37,89 +66,70 @@ interface Offer {
lastMessageTimestamp: string;
market_value: string;
offer_value: string;
}
type submitOfferProps = {
offer_id: number,
sender_id: number,
property_id: number,
}
offer_id: number;
sender_id: number;
property_id: number;
};
const Offers = (): ReactElement => {
const [offers, setOffers] = useState<Offer[]>([]);
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
const {account} = useContext(AccountContext)
const { account } = useContext(AccountContext);
const [showDialog, setShowDialog] = useState<boolean>(false);
const closeDialog = () => {
setShowDialog(false);
}
};
const createOffer = async (property_id: number) => {
console.log(account)
if(account)
{
console.log(account);
if (account) {
console.log({
user: account.id,
property: property_id,
})
response = await axiosInstance.post(`/offers/`,
{
});
response = await axiosInstance.post(`/offers/`, {
user: account.id,
property: property_id,
})
});
setShowDialog(false);
setShowDialog(false)
setShowDialog(false);
}
}
};
const submitOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {
response = await axiosInstance.put(`/offers/${offer_id}/`,
{
response = await axiosInstance.put(`/offers/${offer_id}/`, {
user: sender_id,
property: property_id,
status:'submitted'
})
console.log(response)
status: 'submitted',
});
console.log(response);
// TODO: update the selectedOffer' status
const updatedOffers: Offer[] = offers.map(item => ({
const updatedOffers: Offer[] = offers.map((item) => ({
...item, // Spread operator to copy existing properties
status: 'submitted'
status: 'submitted',
}));
setOffers(updatedOffers);
}
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
}
};
const withdrawOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {};
useEffect(() => {
const fetchOffers = async () => {
try {
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/')
console.log(data)
const { data }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/');
console.log(data);
if (data.length > 0) {
console.log(data)
const fetchedOffers: Offer[] = data.map(item => {
console.log(item)
console.log(data);
const fetchedOffers: Offer[] = data.map((item) => {
console.log(item);
return {
id: item.id,
sender: item.user.first_name + " " + item.user.last_name,
sender: item.user.first_name + ' ' + item.user.last_name,
status: item.status,
address: item.property.address,
is_active: item.is_active,
@@ -127,48 +137,52 @@ const Offers = (): ReactElement => {
market_value: item.property.market_value,
offer_value: '100000',
sender_id: item.user.id,
property_id: item.property.id
}
})
console.log(fetchedOffers)
property_id: item.property.id,
};
});
console.log(fetchedOffers);
setOffers(fetchedOffers);
}
}catch(error){
}
}
} catch (error) {}
};
fetchOffers();
}, [])
}, []);
type offerChoice = 'accept' | 'counter' | 'reject';
const handleOffer = async (choice: offerChoice) => {
console.log(choice)
}
console.log(choice);
};
const selectedOffer = offers.find(
(conv) => conv.id === selectedOfferId
);
const selectedOffer = offers.find((conv) => conv.id === selectedOfferId);
return (
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
<Container
maxWidth="lg"
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
>
<Paper
elevation={3}
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
>
<Grid container sx={{ height: '100%' }}>
{/* Left Panel: Offer List */}
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
<Grid
size={{ xs: 12, md: 4 }}
sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
<Stack direction="row" sx={{ width: '100%' }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
Offers
</Typography>
<Button
variant='contained'
color='primary'
variant="contained"
color="primary"
sx={{ ml: 'auto' }}
onClick={() => setShowDialog(true)}
>
@@ -184,7 +198,11 @@ const handleOffer = async (choice: offerChoice) => {
</Box>
) : (
offers
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
.sort(
(a, b) =>
new Date(b.lastMessageTimestamp).getTime() -
new Date(a.lastMessageTimestamp).getTime(),
)
.map((conv) => (
<ListItem
key={conv.id}
@@ -200,8 +218,19 @@ const handleOffer = async (choice: offerChoice) => {
</Typography>
}
secondary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
<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">
@@ -217,13 +246,25 @@ const handleOffer = async (choice: offerChoice) => {
</Grid>
{/* Right Panel: Offer Detail */}
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
<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 }}>
<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('')}
{selectedOffer.sender
.split(' ')
.map((n) => n[0])
.join('')}
</Avatar>
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{selectedOffer.sender}
@@ -234,9 +275,7 @@ const handleOffer = async (choice: offerChoice) => {
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
{/* add the offer details here */}
<Accordion>
<AccordionSummary>
Offer for {selectedOffer.address}
</AccordionSummary>
<AccordionSummary>Offer for {selectedOffer.address}</AccordionSummary>
<AccordionDetails>
<Typography>
Offer Price: <strong>{selectedOffer.offer_value}</strong>
@@ -252,9 +291,17 @@ const handleOffer = async (choice: offerChoice) => {
</Typography>
</AccordionDetails>
<AccordionActions>
{selectedOffer.status === 'submitted' ? (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
p: 2,
borderTop: '1px solid',
borderColor: 'grey.200',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<Button
variant="contained"
color="primary"
@@ -283,13 +330,27 @@ const handleOffer = async (choice: offerChoice) => {
Reject
</Button>
</Box>
) : (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
<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})}
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 }}
>
@@ -298,43 +359,55 @@ const handleOffer = async (choice: offerChoice) => {
<Button
variant="contained"
color="primary"
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
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' }}>
<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>
<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} />
<CreateOfferDialog
showDialog={showDialog}
createOffer={createOffer}
closeDialog={closeDialog}
/>
</Paper>
</Container>
);
}
};
export default Offers;

View File

@@ -4,12 +4,25 @@ import { PropertiesAPI } from 'types';
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
// Reusing the mockProperties from PropertyDetailPage for consistent data
const mockProperties: PropertiesAPI[] = [
{
id: 101,
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
owner: {
user: {
id: 1,
email: 'john.doe@example.com',
first_name: 'John',
last_name: 'Doe',
user_type: 'property_owner',
is_active: true,
date_joined: '2023-01-15',
tos_signed: true,
profile_created: true,
tier: 'basic',
},
phone_number: '123-456-7890',
},
address: '123 Main St',
city: 'Anytown',
state: 'CA',
@@ -24,7 +37,8 @@ const mockProperties: PropertiesAPI[] = [
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
'https://via.placeholder.com/600x400?text=Property+1+Living',
],
description: 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
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,
@@ -34,7 +48,21 @@ const mockProperties: PropertiesAPI[] = [
},
{
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' },
owner: {
user: {
id: 1,
email: 'john.doe@example.com',
first_name: 'John',
last_name: 'Doe',
user_type: 'property_owner',
is_active: true,
date_joined: '2023-01-15',
tos_signed: true,
profile_created: true,
tier: 'basic',
},
phone_number: '123-456-7890',
},
address: '456 Oak Ave',
city: 'Anytown',
state: 'CA',
@@ -52,11 +80,25 @@ const mockProperties: PropertiesAPI[] = [
num_bedrooms: 4,
num_bathrooms: 3,
latitude: 34.075, // Another example coordinate
longitude: -118.30,
longitude: -118.3,
},
{
id: 103,
owner: { user: { id: 99, email: 'another.owner@example.com', first_name: 'Jane', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '987-654-3210' },
owner: {
user: {
id: 99,
email: 'another.owner@example.com',
first_name: 'Jane',
last_name: 'Doe',
user_type: 'property_owner',
is_active: true,
date_joined: '2023-01-15',
tos_signed: true,
profile_created: true,
tier: 'basic',
},
phone_number: '987-654-3210',
},
address: '789 Pine Lane',
city: 'Otherville',
state: 'NY',
@@ -74,11 +116,25 @@ const mockProperties: PropertiesAPI[] = [
num_bedrooms: 2,
num_bathrooms: 2,
latitude: 40.7128, // NYC
longitude: -74.0060,
longitude: -74.006,
},
{
id: 104,
owner: { user: { id: 100, email: 'test.user@example.com', first_name: 'Bob', last_name: 'Brown', user_type: 'property_owner', is_active: true, date_joined: '2024-01-01', tos_signed: true, profile_created: true, tier: 'premium' }, phone_number: '555-987-6543' },
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',
@@ -96,31 +152,46 @@ const mockProperties: PropertiesAPI[] = [
num_bedrooms: 3,
num_bathrooms: 2,
latitude: 32.7767, // Dallas
longitude: -96.7970,
}
longitude: -96.797,
},
];
const Property: React.FC = () => {
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
const [initialLoad, setInitialLoad] = useState(true);
const filterProperties = (filters: any) => {
const filtered = mockProperties.filter(property => {
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
const filtered = mockProperties.filter((property) => {
const addressMatch = filters.address
? property.address.toLowerCase().includes(filters.address.toLowerCase())
: true;
const cityMatch = filters.city
? property.city.toLowerCase().includes(filters.city.toLowerCase())
: true;
const stateMatch = filters.state
? property.state.toLowerCase() === filters.state.toLowerCase()
: true;
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
const sqFtMatch =
(filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
const bedroomsMatch =
(filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
const bathroomsMatch =
(filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
sqFtMatch && bedroomsMatch && bathroomsMatch;
return (
addressMatch &&
cityMatch &&
stateMatch &&
zipCodeMatch &&
sqFtMatch &&
bedroomsMatch &&
bathroomsMatch
);
});
setSearchResults(filtered);
setInitialLoad(false);
@@ -143,16 +214,18 @@ const Property: React.FC = () => {
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
{initialLoad
? 'Enter search criteria to find properties.'
: `Search Results (${searchResults.length} found)`}
</Typography>
<Grid container spacing={2}>
{searchResults.length === 0 && !initialLoad ? (
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<Alert severity="info">No properties found matching your criteria.</Alert>
</Grid>
) : (
searchResults.map(property => (
<Grid item xs={12} sm={6} md={4} key={property.id}>
searchResults.map((property) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={property.id}>
<PropertyListItem
property={property}
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
@@ -185,7 +258,6 @@ export default Property;
// import HouseIcon from '@mui/icons-material/House';
// import { formatTimestamp } from 'utils';
// const Property = (): ReactElement => {
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
@@ -203,7 +275,6 @@ export default Property;
// }
// }catch(error){
// console.log(error)
// }
@@ -224,7 +295,6 @@ export default Property;
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
// <Stack direction="row" sx={{width:'100%'}}>
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
// Properties
// </Typography>
@@ -333,7 +403,6 @@ export default Property;
// </Box>
// </>
// ) : (
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>

View File

@@ -1,7 +1,18 @@
import React, { useState, useEffect, useContext } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material';
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import {
Container,
Typography,
CircularProgress,
Grid,
Alert,
Divider,
Card,
CardContent,
Button,
private_excludeVariablesFromRoot,
} from '@mui/material';
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
@@ -9,16 +20,18 @@ import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseC
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
import { AxiosResponse } from 'axios';
import axios, { AxiosResponse } from 'axios';
import { axiosInstance } from '../../axiosApi';
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
import { AccountContext } from 'contexts/AccountContext';
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
const PropertyDetailPage: React.FC = () => {
// In a real app, you'd get propertyId from URL params or a global state
const { account, accountLoading } = useContext(AccountContext);
const { propertyId } = useParams<{ propertyId: string }>();
const location = useLocation();
const navigate = useNavigate();
const searchParams = new URLSearchParams(location.search);
const isSearch = searchParams.get('search') === '1';
@@ -26,21 +39,15 @@ const PropertyDetailPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [savedProperty, setSavedProperty] = useState<SavedPropertiesAPI | null>(null);
if (accountLoading) {
return <>Page is loading</>;
} else if (!accountLoading && !account) {
return <>There was an error</>;
} else {
useEffect(() => {
// Simulate API call
const getProperty = async () => {
try {
setLoading(true);
setError(null);
const url = isSearch
? `/properties/${propertyId}/?search=1`
: `/properties/${propertyId}/`;
const url = isSearch ? `/properties/${propertyId}/?search=1` : `/properties/${propertyId}/`;
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
if (isSearch) {
// kick the view count
@@ -55,8 +62,19 @@ const PropertyDetailPage: React.FC = () => {
setLoading(false);
}
};
const getSavedProperties = async () => {
if (account) {
// only fetch if logged in
try {
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
await axiosInstance.get('/saved-properties/');
setSavedProperty(data.find((item) => item.property.toString() === propertyId));
} catch (error) {}
}
};
getProperty();
}, [propertyId]);
getSavedProperties();
}, [propertyId, account, isSearch]);
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
// In a real app, this would be an API call to update the property
@@ -66,34 +84,110 @@ const PropertyDetailPage: React.FC = () => {
setTimeout(() => setMessage(null), 3000);
};
const onStatusChange = () => {
const onStatusChange = async (value: string) => {
if (property) {
setProperty((property) => ({ ...property, status: 'active' }));
try {
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, {
property_status: value,
});
setProperty((prev) => (prev ? { ...prev, property_status: value } : null));
setMessage({ type: 'success', text: `Your listing is now ${value}` });
} catch (error) {
setMessage({
type: 'error',
text: 'There was an error saving your selection. Please try again',
});
setTimeout(() => setMessage(null), 3000);
}
} else {
setMessage({
type: 'error',
text: 'There was an error saving your selection. Please refresh the page and try again',
});
setTimeout(() => setMessage(null), 3000);
}
};
const onSavedPropertySave = async () => {
if (property && account) {
try {
const response = await axiosInstance.post(`/saved-properties/`, {
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,
});
console.log(response);
setSavedProperty(data);
setProperty((prev) =>
prev
? {
...prev,
saves: prev.saves + 1,
}
: null,
);
}
} catch (error) {
console.log(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');
console.log('handle delete. IMPLEMENT ME', propertyId);
};
const handleOfferSubmit = (offerAmount: number) => {
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`);
// Here you would send the offer to your backend API
const handleSendMessage = () => {
if (property && property.owner && property.owner.user) {
navigate(`/messages?recipient=${property.owner.user.id}`);
}
};
if (loading) {
const handleOfferSubmit = async (
offerAmount: number,
closing_days: number,
contingencies: string,
): Promise<{ status: number; message?: string }> => {
try {
const response = await axiosInstance.post('/documents/upload/', {
document_type: 'offer',
property: propertyId,
offer_price: offerAmount,
closing_days: closing_days,
contingencies: contingencies,
});
return { status: response.status };
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
message: error.response.data?.detail || 'Failed to submit offer.',
};
}
return {
status: 500,
message: 'An unexpected error occurred.',
};
}
};
if (loading || accountLoading) {
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
<CircularProgress />
@@ -119,16 +213,28 @@ const PropertyDetailPage: React.FC = () => {
}
// Determine if the current user is the owner of this property
const isOwnerOfProperty = account.id === property.owner.user.id;
const isOwnerOfProperty = account ? account.id === property.owner.user.id : false;
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
let listed_price: string;
if (property.listed_price === undefined || property.listed_price === null) {
listed_price = 'No Price';
} else {
listed_price = parseFloat(property.listed_price).toString();
}
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 }}>
@@ -140,61 +246,75 @@ const PropertyDetailPage: React.FC = () => {
<Grid container spacing={3}>
{/* Main Property Details */}
<Grid item xs={12} md={8}>
<Grid size={{ xs: 12, md: 8 }}>
<PropertyDetailCard
property={property}
isPublicPage={true}
onSave={handleSaveProperty}
isOwnerView={isOwnerOfProperty}
onDelete={handleDeleteProperty}
onDelete={() => handleDeleteProperty(property.id)}
/>
</Grid>
{/* Status, Cost, Offers */}
<Grid item xs={12} md={4}>
<Grid size={{ xs: 12, md: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<PropertyStatusCard
property={property}
isOwner={isOwnerOfProperty}
onStatusChange={onStatusChange}
onSavedPropertySave={onSavedPropertySave}
savedProperty={savedProperty}
sellerDisclosureExists={sellerDisclosureExists}
/>
</Grid>
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
</Grid>
<Grid item xs={12}>
{!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 item xs={12}>
<Grid size={{ xs: 12 }}>
<OpenHouseCard openHouses={property.open_houses} />
</Grid>
</Grid>
</Grid>
{/* Additional Information */}
<Grid item xs={12}>
<Grid size={{ xs: 12 }}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid item xs={12} md={4}>
<Grid size={{ xs: 12, md: 4 }}>
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
</Grid>
<Grid item xs={12} md={4}>
<Grid size={{ xs: 12, md: 4 }}>
<WalkScoreCard walkScore={property.walk_score} />
</Grid>
<Grid item xs={12} md={4}>
<Grid size={{ xs: 12, md: 4 }}>
{property.schools && <SchoolCard schools={property.schools} />}
</Grid>
</Grid>
</Container>
);
}
};
export default PropertyDetailPage;

View File

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

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 { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
import DashboardTemplate from 'components/DasboardTemplate';
@@ -8,10 +8,145 @@ import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
import { axiosInstance } from '../../axiosApi';
import {
Box,
Container,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
Typography,
} from '@mui/material';
const base_url: string = `${import.meta.env.VITE_API_URL?.replace('/api/', '')}/media/vendor_pictures/`;
// Define the array of corrected and alphabetized categories with 'as const'
const CATEGORY_NAMES = [
'Arborist',
'Basement Waterproofing And Injection',
'Carpenter',
'Cleaning Company',
'Decking',
'Door Company',
'Electrician',
'Fencing',
'General Contractor',
'Handyman',
'Home Inspector',
'House Staging',
'HVAC',
'Irrigation And Sprinkler System',
'Junk Removal',
'Landscaping',
'Masonry',
'Mortgage Lendor',
'Moving Company',
'Painter',
'Paving Company',
'Pest Control',
'Photographer',
'Plumber',
'Pressure Washing',
'Roofer',
'Storage Facility',
'Window Company',
'Window Washing',
] as const;
const defaultCategoryImages: { [key in VendorType]: string } = {
Arborist: `${base_url}arborist.png`,
'Basement Waterproofing And Injection': `${base_url}basement.png`,
Carpenter: `${base_url}carpenter.png`,
'Cleaning Company': `${base_url}cleaning.png`,
Decking: `${base_url}deck.png`,
'Door Company': `${base_url}door.png`,
Electrician: `${base_url}electrician.png`,
Fencing: `${base_url}fencing.png`,
'General Contractor': `${base_url}general_contractor.png`,
Handyman: `${base_url}handyman.png`,
'Home Inspector': `${base_url}home_inspector.png`,
'House Staging': `${base_url}home_staging.png`,
HVAC: `${base_url}hvac.png`,
'Irrigation And Sprinkler System': `${base_url}irrigation.png`,
'Junk Removal': `${base_url}junk_removal.png`,
Landscaping: `${base_url}landscape.png`,
'Mortgage Lendor': `${base_url}landscape.png`,
Masonry: `${base_url}masonry.png`,
'Moving Company': `${base_url}junk_removal.png`,
Painter: `${base_url}painting.png`,
'Paving Company': `${base_url}paving.png`,
'Pest Control': `${base_url}pest_control.png`,
Photographer: `${base_url}photography.png`,
Plumber: `${base_url}plumber.jpg`,
'Pressure Washing': `${base_url}power_washing.png`,
Roofer: `${base_url}roofer.png`,
'Storage Facility': `${base_url}storage.png`,
'Window Company': `${base_url}window_installation.png`,
'Window Washing': `${base_url}window_company.jpg`,
};
const CATEGRORY_DESCRIPTIONS: { [key in VendorType]: string } = {
Arborist:
'Connect with certified tree specialists for professional tree pruning, health assessment, and safe removal services. Keep your trees beautiful and your property protected.',
'Basement Waterproofing And Injection':
'Protect your biggest investment by finding experts in basement leak repair, foundation sealing, and effective moisture control solutions. Say goodbye to damp and musty spaces.',
Carpenter:
'Hire skilled craftsmen for custom built-ins, framing, trim work, and detailed wood repair projects around your home. Bring your vision of custom woodworking to life.',
'Cleaning Company': `Find reliable residential and commercial cleaning services to maintain a spotless and healthy environment. Whether it's a deep clean or regular maintenance, they handle the dirty work.`,
Decking:
'Design and build the perfect outdoor living space with professionals specializing in new deck construction, repairs, and staining. Get ready to enjoy your backyard retreat.',
'Door Company': `Install beautiful and secure entry, patio, and interior doors that enhance your home's curb appeal and energy efficiency. Upgrade your home's look and security.`,
Electrician:
'Get trusted service for wiring upgrades, lighting installation, electrical repairs, and safety inspections from licensed professionals. Power your home safely and efficiently.',
Fencing:
'Secure your property and enhance privacy with experts in wood, vinyl, metal, and chain-link fence installation and repair. Define your boundaries with style.',
'General Contractor':
'Oversee your entire home remodel, new construction, or major renovation project with a single trusted project manager. They coordinate all trades to complete your vision seamlessly.',
Handyman:
'For those small repairs and maintenance tasks that pile up, find a versatile professional to handle various jobs quickly and efficiently. Check off your to-do list with ease.',
'Home Inspector': `Get a detailed, unbiased report on the condition of a potential home purchase or sale from a certified inspector. Know exactly what you're buying or selling before you close.`,
'House Staging': `Prepare your home to sell faster and for a higher price with professional staging services that highlight your home's best features. Make a stunning first impression on potential buyers.`,
HVAC: 'Keep your home comfortable year-round with experts in heating, ventilation, and air conditioning system repair, maintenance, and new installation. Control your climate efficiently.',
'Irrigation And Sprinkler System': `Install, repair, and maintain efficient sprinkler systems that keep your lawn and garden healthy without wasting water. Automate your yard's watering needs.`,
'Junk Removal':
'Clear out clutter, construction debris, or unwanted items with fast and responsible hauling and disposal services. Reclaim your space and let them handle the heavy lifting.',
Landscaping:
'Transform your yard with design, installation, and maintenance services for beautiful lawns, gardens, and outdoor features. Create the perfect curb appeal and living space outside.',
Masonry:
'Restore or construct durable, attractive structures using brick, stone, and concrete for patios, walls, fireplaces, and foundations. Find skilled artisans for lasting results.',
'Mortgage Lendor':
'Connect with financing professionals to guide you through the process of securing a new home loan or refinancing your current one. Find the best rates and terms for your financial needs.',
'Moving Company':
'Hire reliable and insured movers for local or long-distance relocation, packing, and secure transportation of your belongings. Enjoy a smooth, stress-free move to your new home.',
Painter: `Refresh your home's interior and exterior with professional painting services, including prep work, color consultation, and a flawless finish. Give your home a vibrant new look.`,
'Paving Company':
'Install and repair durable asphalt and concrete surfaces for driveways, walkways, and patios. Find experts to enhance the accessibility and curb appeal of your property.',
'Pest Control':
'Protect your home from unwanted guests like insects, rodents, and wildlife with effective, preventative, and removal treatments. Keep your home safe and pest-free.',
Photographer:
'Capture stunning, high-quality images of your property for real estate listings, rentals, or design portfolios. Professional photos make a significant difference in marketing your home.',
Plumber:
'For leaks, clogs, fixture installation, and water heater repair, connect with licensed plumbing professionals for reliable service. Ensure your water systems are running smoothly.',
'Pressure Washing': `Revitalize your home's exterior, driveway, deck, and siding by removing dirt, grime, mold, and mildew. Bring back the original sparkle and brightness to your property.`,
Roofer:
'Find expert contractors for new roof installation, leak repair, and routine inspections to protect your home from the elements. Secure your home with a reliable, durable roof.',
'Storage Facility':
'Locate secure, convenient, and affordable short-term or long-term storage solutions for your personal or business belongings. Declutter your space without throwing things away.',
'Window Company':
'Upgrade your home with energy-efficient window replacement and installation to improve comfort and lower utility bills. Find the perfect styles to enhance natural light and aesthetics.',
'Window Washing':
'Schedule professional interior and exterior window cleaning to achieve streak-free, crystal-clear views. Let the natural light flood your home and boost your curb appeal.',
} as const;
export type VendorType = (typeof CATEGORY_NAMES)[number];
const Vendors = (): ReactElement => {
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
const [hideEmptyCategories, setHideEmptyCategories] = useState<boolean>(true); // Default to true
const [searchRadius, setSearchRadius] = useState<number>(10);
// Simulate fetching data
let fetchedVendors: VendorItem[] = [];
@@ -51,42 +186,26 @@ const Vendors = (): ReactElement => {
}
>();
const defaultCategoryImages: { [key: string]: string } = {
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician',
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber',
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping',
};
fetchedVendors.forEach((vendor) => {
const categoryId = vendor.categoryId;
if (!categoryMap.has(categoryId)) {
let categoryName = '';
switch (categoryId) {
case 'electrician':
categoryName = 'Electricians';
break;
case 'plumber':
categoryName = 'Plumbers';
break;
case 'landscaping':
categoryName = 'Landscaping';
break;
default:
categoryName = 'Other Service';
}
CATEGORY_NAMES.forEach((categoryName) => {
const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
categoryMap.set(categoryId, {
name: categoryName,
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
description: CATEGRORY_DESCRIPTIONS[categoryName],
imageUrl:
defaultCategoryImages[categoryId] ||
defaultCategoryImages[categoryName] ||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
numVendors: 0,
totalRating: 0,
});
}
});
fetchedVendors.forEach((vendor) => {
const categoryId = vendor.categoryId;
if (categoryMap.has(categoryId)) {
const categoryData = categoryMap.get(categoryId)!;
categoryData.numVendors += 1;
categoryData.totalRating += vendor.rating;
}
});
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
@@ -105,16 +224,88 @@ const Vendors = (): ReactElement => {
fetchData();
}, []);
const filteredCategories = useMemo(() => {
// 1. Hide empty categories if the toggle is set to true
let categoriesToRender = vendorCategories;
console.log(vendorCategories);
if (hideEmptyCategories) {
categoriesToRender = vendorCategories.filter((cat) => cat.numVendors > 0);
}
// 2. Geographic filtering based on searchRadius would go here.
// This would typically involve user location and an API call/client-side distance calculation
// filteredCategories = categoriesToRender.filter(category =>
// category.vendors.some(vendor => isWithinRadius(vendor.location, userLocation, searchRadius))
// );
return categoriesToRender;
}, [vendorCategories, hideEmptyCategories, searchRadius]);
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
{/* VENDOR FILTERS HEADER AND CONTROLS */}
<Paper
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
flexWrap: 'wrap',
}}
>
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
Vendor Filters
</Typography>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: 'center',
mt: { xs: 1, sm: 0 },
}}
>
{/* TOGGLE: Hide Categories with No Vendors */}
<FormControlLabel
control={
<Switch
checked={hideEmptyCategories}
onChange={(e) => setHideEmptyCategories(e.target.checked)}
name="hideEmpty"
color="primary"
/>
}
label="Hide Empty Categories"
/>
{/* DROPDOWN: Search Radius */}
{/*<FormControl variant="outlined" sx={{ minWidth: 120 }} size="small">
<InputLabel id="search-radius-label">Search Radius</InputLabel>
<Select
labelId="search-radius-label"
id="search-radius-select"
value={searchRadius}
onChange={(e) => setSearchRadius(e.target.value as number)}
label="Search Radius"
>
<MenuItem value={10}>10 miles</MenuItem>
<MenuItem value={25}>25 miles</MenuItem>
<MenuItem value={50}>50 miles</MenuItem>
</Select>
</FormControl>*/}
</Box>
</Paper>
<DashboardTemplate<VendorCategory, VendorItem>
pageTitle="Service Vendors"
data={{ categories: vendorCategories, items: allVendors }}
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} />
<VendorCategoryCard
category={category as VendorCategory}
onSelectCategory={onSelect}
/>
)}
/>
)}
@@ -136,6 +327,7 @@ const Vendors = (): ReactElement => {
/>
)}
/>
</Container>
);
};

View File

@@ -24,8 +24,7 @@ import { AuthContext } from 'contexts/AuthContext.js';
type loginValues = {
email: string;
password: string;
}
};
const Login = (): ReactElement => {
const [showPassword, setShowPassword] = useState(false);
@@ -38,32 +37,27 @@ const Login = (): ReactElement => {
const handleLogin = async ({ email, password }: loginValues): Promise<void> => {
try {
const response = await axiosInstance.post('/token/',
{
const response = await axiosInstance.post('/token/', {
email: email,
password: password
}
)
password: password,
});
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
localStorage.setItem('access_token', response.data.access);
localStorage.setItem('refresh_token', response.data.refresh);
const get_user_response = await axiosInstance.get('/user/')
setAuthentication(true)
navigate("/")
const get_user_response = await axiosInstance.get('/user/');
setAuthentication(true);
navigate('/dashboard');
} catch (error) {
const hasErrors = Object.keys(error.response.data).length > 0;
if (hasErrors) {
setErrorMessage(error.response.data)
setErrorMessage(error.response.data);
} else {
setErrorMessage(null);
}
}
}
};
return (
<Stack
@@ -87,30 +81,31 @@ const Login = (): ReactElement => {
onSubmit={handleLogin}
>
{({ setFieldValue }) => (
<Form>
<FormControl variant="standard" fullWidth>
{errorMessage ? (
<Alert severity='error'>
<Alert severity="error">
{errorMessage.detail ? (
<Typography>{errorMessage.detail}</Typography>
) : (
<ul>
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
<li key={fieldName}>
<strong>{fieldName}</strong>
{errorMessages.length > 0 ? (
{Array.isArray(errorMessages) ? (
<ul>
{errorMessages.map((message, index) => (
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
<li key={`${fieldName}-${index}`}>{message}</li>
))}
</ul>
) : (
<span> No specific errors for this field.</span>
<span>: {String(errorMessages)}</span>
)}
</li>
))}
</ul>
)}
</Alert>
) : null}
<InputLabel shrink htmlFor="email">
Email

View File

@@ -17,10 +17,12 @@ import passwordUpdated from 'assets/authentication-banners/password-updated.png'
import successTick from 'assets/authentication-banners/successTick.png';
import Image from 'components/base/Image';
import IconifyIcon from 'components/base/IconifyIcon';
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
const ResetPassword = (): ReactElement => {
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [password, setPassword] = useState('');
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
@@ -65,6 +67,7 @@ const ResetPassword = (): ReactElement => {
placeholder="Enter new password"
type={showNewPassword ? 'text' : 'password'}
id="new-password"
onChange={(e) => setPassword(e.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -86,6 +89,7 @@ const ResetPassword = (): ReactElement => {
),
}}
/>
<PasswordStrengthChecker password={password} />
</FormControl>
<FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="confirm-password">

View File

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

View File

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

View File

@@ -29,9 +29,25 @@ import ProfilePage from 'pages/Profile/Profile';
import Dashboard from 'pages/home/Dashboard';
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
import PropertySearchPage from 'pages/Property/PropertySearchPage';
import PublicPropertySearch from 'pages/Property/PublicPropertySearch';
import BidsPage from 'pages/Bids/Bids';
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
import UpgradePage from 'pages/Upgrade/UpgradePage';
import DocumentManager from 'components/sections/dashboard/Home/Documents/DocumentManager';
import { Typography } from '@mui/material';
import PublicPropertyDetail from 'pages/Property/PublicPropertyDetail';
import FAQPage from 'pages/Support/FAQ';
import SupportManager from 'pages/Support/SupportManager';
const RootRedirect = () => {
const { authenticated } = useContext(AuthContext);
if (authenticated) {
return <Navigate to="/dashboard" replace />;
}
return <PublicPropertySearch />;
};
const App = lazy(() => import('App'));
const MainLayout = lazy(async () => {
@@ -47,6 +63,13 @@ const AuthLayout = lazy(async () => {
]).then(([moduleExports]) => moduleExports);
});
const PublicLayout = lazy(async () => {
return Promise.all([
import('layouts/public-layout'),
new Promise((resolve) => setTimeout(resolve, 1000)),
]).then(([moduleExports]) => moduleExports);
});
const Error404 = lazy(async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
return import('pages/errors/Error404');
@@ -92,7 +115,7 @@ const routes: RouteObject[] = [
),
children: [
{
path: rootPaths.homeRoot,
path: rootPaths.dashboardRoot,
element: (
<ProtectedRoute>
<MainLayout>
@@ -104,9 +127,41 @@ const routes: RouteObject[] = [
),
children: [
{
path: paths.home,
path: paths.dashboard,
element: <Dashboard />,
//element: <Sales />,
},
],
},
{
path: rootPaths.homeRoot,
element: (
<PublicLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</PublicLayout>
),
children: [
{
path: paths.publicPropertySearch,
element: <RootRedirect />,
},
],
},
{
path: rootPaths.publicRoot,
element: (
<PublicLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</PublicLayout>
),
children: [
{
path: paths.publicPropertyDetail,
element: <PublicPropertyDetail />,
},
],
},
@@ -175,6 +230,20 @@ const routes: RouteObject[] = [
path: paths.propertyDetail,
element: <PropertyDetailPage />,
},
],
},
{
path: rootPaths.propertySearchRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.propertySearch,
element: <PropertySearchPage />,
@@ -259,6 +328,25 @@ const routes: RouteObject[] = [
},
],
},
{
path: rootPaths.documentsRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.documents,
element: <DocumentManager />,
},
],
},
{
path: rootPaths.bidsRoot,
@@ -316,6 +404,29 @@ const routes: RouteObject[] = [
},
],
},
{
path: rootPaths.supportRoot,
element: (
<ProtectedRoute>
<MainLayout>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</MainLayout>
</ProtectedRoute>
),
children: [
{
path: paths.support,
element: <FAQPage />,
},
{
path: paths.supportManager,
element: <SupportManager />,
},
],
},
{
path: rootPaths.profileRoot,

View File

@@ -1,5 +1,9 @@
// src/templates/types.ts
import { HomeImprovementReceiptData } from 'components/sections/dashboard/Home/Documents/Dialog/HomeImprovementReciptDialogContent';
import { OfferData } from 'components/sections/dashboard/Home/Documents/Dialog/OfferDialogContent';
import { SellerDisclousureData } from 'components/sections/dashboard/Home/Documents/Dialog/SellerDisclousureDialogContent';
export interface NavItem {
title: string;
path: string;
@@ -105,6 +109,14 @@ export interface PropertyOwnerAPI {
phone_number: string;
}
export interface OpenHouseAPI {
id: number;
property: number | PropertiesAPI; // It can be an ID or the full object
start_time: string;
end_time: string;
listed_date: string;
}
export interface VendorAPI {
user: UserAPI;
business_name: string;
@@ -222,10 +234,6 @@ export interface TaxHistoryAPI {
year: number;
}
export interface OpenHouseAPI {
lsited_date: string;
}
export interface SchoolAPI {
id?: number;
address: string;
@@ -297,6 +305,7 @@ export interface PropertiesAPI {
tax_info: TaxHistoryAPI;
open_houses?: OpenHouseAPI[];
schools: SchoolAPI[];
documents?: DocumentAPI[];
}
export interface BidImageAPI {
@@ -327,6 +336,19 @@ export interface BidAPI {
updated_at: string;
}
export interface DocumentAPI {
id: number;
property: number;
file: string;
document_type: string;
description?: string;
uploaded_by: number; // or a more detailed user object
shared_with: number[];
updated_at: string;
created_at: string;
sub_document?: SellerDisclousureData | HomeImprovementReceiptData | OfferData;
}
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
owner: number;
}
@@ -711,4 +733,37 @@ export interface PropertyResponseAPI {
compsLookupExecutionTimeMS: string;
}
export interface SavedPropertiesAPI {
id: number;
user: number;
property: number;
created_at: string;
}
export interface FaqApi {
order: number;
answer: string;
question: string;
}
export interface SupportMessageApi {
id: number;
text: string;
user: id;
user_first_name: string;
user_last_name: string;
created_at: string;
updated_at: string;
}
export interface SupportCaseApi {
id: number;
title: string;
description: string;
category: string;
status: string;
messages: SupportMessageApi[];
created_at: string;
updated_at: string;
}
// Walk Score API Type Definitions

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
}
/**
* 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);
};