inital checkin before beta launch
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
|
REACT_APP_Maps_API_KEY="AIzaSyDJTApP1OoMbo7b1CPltPu8IObxe8UQt7w"
|
||||||
|
REAL_ESTATE_API_KEY=AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70
|
||||||
|
|||||||
3
ditch-the-agent/.env.beta
Normal file
3
ditch-the-agent/.env.beta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=https://beta.backend.ditchtheagent.com/api/
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
USE_LIVE_DATA=false
|
||||||
3
ditch-the-agent/.env.development
Normal file
3
ditch-the-agent/.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=http://127.0.0.1:8010/api/
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
USE_LIVE_DATA=false
|
||||||
3
ditch-the-agent/.env.production
Normal file
3
ditch-the-agent/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_URL=https://backend.ditchtheagent.com/api/
|
||||||
|
ENABLE_REGISTRATION=false
|
||||||
|
USE_LIVE_DATA=true
|
||||||
904
ditch-the-agent/package-lock.json
generated
904
ditch-the-agent/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,59 @@
|
|||||||
{
|
{
|
||||||
"name": "mui-dta-dashboard",
|
"name": "mui-dta-dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"build:beta": "tsc && vite build --mode beta",
|
||||||
"preview": "vite preview",
|
"build:prod": "tsc && vite build --mode production",
|
||||||
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"deploy": "gh-pages -d dist"
|
"preview": "vite preview",
|
||||||
},
|
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
||||||
"dependencies": {
|
"deploy": "gh-pages -d dist"
|
||||||
"@emotion/react": "^11.11.4",
|
},
|
||||||
"@emotion/styled": "^11.11.5",
|
"dependencies": {
|
||||||
"@mui/material": "^5.15.14",
|
"@emotion/react": "^11.11.4",
|
||||||
"@mui/x-data-grid": "^7.2.0",
|
"@emotion/styled": "^11.11.5",
|
||||||
"@mui/x-data-grid-generator": "^7.2.0",
|
"@mui/icons-material": "^7.3.2",
|
||||||
"@react-google-maps/api": "^2.20.7",
|
"@mui/material": "^7.3.2",
|
||||||
"@vis.gl/react-google-maps": "^1.5.4",
|
"@mui/x-data-grid": "^7.2.0",
|
||||||
"axios": "^1.10.0",
|
"@mui/x-data-grid-generator": "^7.2.0",
|
||||||
"dayjs": "^1.11.10",
|
"@mui/x-date-pickers": "^8.11.2",
|
||||||
"echarts": "^5.5.0",
|
"@react-google-maps/api": "^2.20.7",
|
||||||
"echarts-for-react": "^3.0.2",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"formik": "^2.4.6",
|
"@vis.gl/react-google-maps": "^1.5.4",
|
||||||
"js-cookie": "^3.0.5",
|
"axios": "^1.10.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.525.0",
|
"dayjs": "^1.11.10",
|
||||||
"material-ui-popup-state": "^5.1.0",
|
"echarts": "^5.5.0",
|
||||||
"react": "^18.2.0",
|
"echarts-for-react": "^3.0.2",
|
||||||
"react-dom": "^18.2.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"react-router-dom": "^6.22.3",
|
"formik": "^2.4.6",
|
||||||
"simplebar-react": "^3.2.5"
|
"js-cookie": "^3.0.5",
|
||||||
},
|
"jwt-decode": "^4.0.0",
|
||||||
"devDependencies": {
|
"lucide-react": "^0.525.0",
|
||||||
"@iconify/react": "^4.1.1",
|
"material-ui-popup-state": "^5.1.0",
|
||||||
"@types/react": "^18.2.66",
|
"react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.22",
|
"react-dom": "^18.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"react-router-dom": "^6.22.3",
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
"simplebar-react": "^3.2.5",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"zxcvbn": "^4.4.2"
|
||||||
"eslint": "^8.57.0",
|
},
|
||||||
"eslint-plugin-prettier": "^5.5.3",
|
"devDependencies": {
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"@iconify/react": "^4.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"@types/react": "^18.2.66",
|
||||||
"typescript": "^5.2.2",
|
"@types/react-dom": "^18.2.22",
|
||||||
"vite": "^5.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
}
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
}
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-prettier": "^5.5.3",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
const baseURL = 'http://127.0.0.1:8010/api/';
|
const baseURL = import.meta.env.VITE_API_URL;
|
||||||
//const baseURL = 'https://backend.ditchtheagent.com/api/';
|
console.log(baseURL);
|
||||||
|
|
||||||
export const axiosRealEstateApi = axios.create({
|
export const axiosRealEstateApi = axios.create({
|
||||||
baseURL: 'https://api.realestateapi.com/v2/',
|
baseURL: 'https://api.realestateapi.com/v2/',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-API-Key': 'AIMLOPERATIONSLLC-a7ce-7525-b38f-cf8b4d52ca70',
|
'X-API-Key': import.meta.env.REAL_ESTATE_API_KEY,
|
||||||
'X-User-Id': 'UniqueUserIdentifier',
|
'X-User-Id': 'UniqueUserIdentifier',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, { ReactNode } from 'react';
|
|||||||
import { Grid } from '@mui/material';
|
import { Grid } from '@mui/material';
|
||||||
import { GenericCategory } from 'types';
|
import { GenericCategory } from 'types';
|
||||||
|
|
||||||
|
|
||||||
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
|
interface CategoryGridTemplateProps<TCategory extends GenericCategory> {
|
||||||
categories: TCategory[];
|
categories: TCategory[];
|
||||||
onSelectCategory: (categoryId: string) => void;
|
onSelectCategory: (categoryId: string) => void;
|
||||||
@@ -18,7 +17,7 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
|
|||||||
return (
|
return (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={category.id}>
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={category.id}>
|
||||||
{renderCategoryCard(category, onSelectCategory)}
|
{renderCategoryCard(category, onSelectCategory)}
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
@@ -26,4 +25,4 @@ function CategoryGridTemplate<TCategory extends GenericCategory>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CategoryGridTemplate;
|
export default CategoryGridTemplate;
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
// src/templates/ItemListDetailTemplate.tsx
|
// src/templates/ItemListDetailTemplate.tsx
|
||||||
import React, { useState, useEffect, ReactNode } from 'react';
|
import React, { useState, useEffect, ReactNode } from 'react';
|
||||||
import { Box, Grid, List, ListItem, ListItemText, Typography, Paper, Button, Stack, IconButton } from '@mui/material';
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
import { GenericCategory, GenericItem } from 'types';
|
import { GenericCategory, GenericItem } from 'types';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
|
||||||
interface ItemListDetailTemplateProps<TCategory extends GenericCategory, TItem extends GenericItem> {
|
interface ItemListDetailTemplateProps<
|
||||||
|
TCategory extends GenericCategory,
|
||||||
|
TItem extends GenericItem,
|
||||||
|
> {
|
||||||
category: TCategory;
|
category: TCategory;
|
||||||
items: TItem[];
|
items: TItem[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
renderListItem: (item: TItem, isSelected: boolean, onSelect: (itemId: string) => void) => ReactNode;
|
renderListItem: (
|
||||||
|
item: TItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
onSelect: (itemId: string) => void,
|
||||||
|
) => ReactNode;
|
||||||
renderItemDetail: (item: TItem) => ReactNode;
|
renderItemDetail: (item: TItem) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,36 +40,35 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
|||||||
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
|
const [selectedItemId, setSelectedItemId] = useState<number | string | null>(null);
|
||||||
|
|
||||||
// Default to the first item in the list
|
// Default to the first item in the list
|
||||||
let temp = null
|
let temp = null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
temp = items[0].id
|
temp = items[0].id;
|
||||||
setSelectedItemId(items[0].id);
|
setSelectedItemId(items[0].id);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
setSelectedItemId(null);
|
setSelectedItemId(null);
|
||||||
}
|
}
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
|
const selectedItem = selectedItemId ? items.find((item) => item.id === selectedItemId) : null;
|
||||||
|
|
||||||
console.log(selectedItemId, selectedItem)
|
console.log(selectedItemId, selectedItem);
|
||||||
|
|
||||||
const handleItemSelect = (itemId: string) => {
|
const handleItemSelect = (itemId: string) => {
|
||||||
setSelectedItemId(itemId);
|
setSelectedItemId(itemId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} md={4}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
|
<Paper elevation={2} sx={{ p: 2, height: '100%', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
|
||||||
<Stack direction="row">
|
<Stack direction="row">
|
||||||
<IconButton size='small' color="inherit" onClick={onBack} sx={{mr:1}}>
|
<IconButton size="small" color="inherit" onClick={onBack} sx={{ mr: 1 }}>
|
||||||
<ArrowBackIcon />
|
<ArrowBackIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6" gutterBottom>{category.name} List</Typography>
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{category.name} List
|
||||||
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<List>
|
<List>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -62,12 +79,23 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
|||||||
</List>
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={8}>
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
renderItemDetail(selectedItem)
|
renderItemDetail(selectedItem)
|
||||||
) : (
|
) : (
|
||||||
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Paper
|
||||||
<Typography variant="h6" color="text.secondary">Select an item to view details</Typography>
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Select an item to view details
|
||||||
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -75,4 +103,4 @@ function ItemListDetailTemplate<TCategory extends GenericCategory, TItem extends
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ItemListDetailTemplate;
|
export default ItemListDetailTemplate;
|
||||||
|
|||||||
61
ditch-the-agent/src/components/PasswordStrengthChecker.tsx
Normal file
61
ditch-the-agent/src/components/PasswordStrengthChecker.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
import { Box, LinearProgress, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface PasswordStrengthCheckerProps {
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasswordStrengthChecker: React.FC<PasswordStrengthCheckerProps> = ({ password }) => {
|
||||||
|
const strength = password ? zxcvbn(password).score : 0;
|
||||||
|
|
||||||
|
const getStrengthLabel = () => {
|
||||||
|
switch (strength) {
|
||||||
|
case 0:
|
||||||
|
return 'Weak';
|
||||||
|
case 1:
|
||||||
|
return 'Fair';
|
||||||
|
case 2:
|
||||||
|
return 'Good';
|
||||||
|
case 3:
|
||||||
|
return 'Strong';
|
||||||
|
case 4:
|
||||||
|
return 'Very Strong';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthColor = () => {
|
||||||
|
switch (strength) {
|
||||||
|
case 0:
|
||||||
|
return 'error';
|
||||||
|
case 1:
|
||||||
|
return 'warning';
|
||||||
|
case 2:
|
||||||
|
return 'info';
|
||||||
|
case 3:
|
||||||
|
return 'success';
|
||||||
|
case 4:
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(strength + 1) * 20}
|
||||||
|
color={getStrengthColor()}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
{getStrengthLabel()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordStrengthChecker;
|
||||||
@@ -1,25 +1,36 @@
|
|||||||
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
|
import { Paper, Container, Grid, Box, Typography, Skeleton } from '@mui/material';
|
||||||
|
|
||||||
import { ReactElement} from 'react';
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
const LoadingSkeleton = (): ReactElement => {
|
const LoadingSkeleton = (): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<Container
|
||||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
maxWidth="lg"
|
||||||
<Grid container sx={{ height: '100%' }}>
|
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
>
|
||||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
|
<Paper
|
||||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
elevation={3}
|
||||||
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
</Typography>
|
>
|
||||||
</Box>
|
<Grid container sx={{ height: '100%' }}>
|
||||||
</Grid>
|
<Grid
|
||||||
</Grid>
|
size={{ xs: 12, sm: 6, md: 4 }}
|
||||||
</Paper>
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200' }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
</Container>
|
export default LoadingSkeleton;
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LoadingSkeleton;
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const VendorBidsPage: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||||
{bids.map((bid) => (
|
{bids.map((bid) => (
|
||||||
<Grid item xs={12} md={6} lg={4} key={bid.id}>
|
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={bid.id}>
|
||||||
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
|
<VendorBidCard bid={bid} onResponseSubmitted={fetchBids} />
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Active Cases Card */}
|
{/* Active Cases Card */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -113,7 +113,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
{/* Upcoming Deadlines Card */}
|
{/* Upcoming Deadlines Card */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -141,7 +141,7 @@ const AttorneyDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
{/* Documents to Review Card */}
|
{/* Documents to Review Card */}
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
import { PropertiesAPI, UserAPI } from 'types';
|
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
|
||||||
import { axiosInstance } from '../../../../../axiosApi';
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
import Grid from '@mui/material/Unstable_Grid2';
|
//==import Grid from '@mui/material/Unstable_Grid2';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Grid,
|
||||||
Card,
|
Card,
|
||||||
CardActionArea,
|
CardActionArea,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -26,6 +27,7 @@ import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
|||||||
import { GridColDef } from '@mui/x-data-grid';
|
import { GridColDef } from '@mui/x-data-grid';
|
||||||
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
import PropertyDetailCard from '../Property/PropertyDetailCard';
|
||||||
import { DashboardProps } from 'pages/home/Dashboard';
|
import { DashboardProps } from 'pages/home/Dashboard';
|
||||||
|
import SavedPropertiesTable from './SavedPropertiesTable';
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -35,6 +37,9 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
const [numBids, setNumBids] = useState<Number>(0);
|
const [numBids, setNumBids] = useState<Number>(0);
|
||||||
const [numOffers, setNumOffers] = useState<Number>(0);
|
const [numOffers, setNumOffers] = useState<Number>(0);
|
||||||
|
const [savedProperties, setSavedProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,16 +74,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const fetchSavedProperties = async () => {
|
||||||
|
try {
|
||||||
|
let expandedSavedProperties: PropertiesAPI[] = [];
|
||||||
|
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
|
||||||
|
await axiosInstance.get('/saved-properties/');
|
||||||
|
const requests = data.map((item) =>
|
||||||
|
axiosInstance.get(`/properties/${item.property}/?search=1`),
|
||||||
|
);
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
|
||||||
|
expandedSavedProperties = responses.map((response) => response.data);
|
||||||
|
console.log(expandedSavedProperties);
|
||||||
|
setSavedProperties(expandedSavedProperties);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchDocuments = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
|
||||||
|
console.log('documents', data);
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
fetchProperties();
|
fetchProperties();
|
||||||
fetchOffers();
|
fetchOffers();
|
||||||
fetchBids();
|
fetchBids();
|
||||||
|
fetchSavedProperties();
|
||||||
|
fetchDocuments();
|
||||||
}, []);
|
}, []);
|
||||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||||
console.log('handle save. IMPLEMENT ME');
|
try {
|
||||||
|
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||||
|
`/properties/${updatedProperty.id}/`,
|
||||||
|
{
|
||||||
|
...updatedProperty,
|
||||||
|
owner: account.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updatedProperties = properties.map((item) => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, ...data };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
setProperties(updatedProperties);
|
||||||
|
setMessage({ type: 'success', text: 'Property has been updated' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProperty = (propertyId: number) => {
|
const handleDeleteProperty = async (propertyId: number) => {
|
||||||
console.log('handle delete. IMPLEMENT ME');
|
try {
|
||||||
|
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||||
|
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||||
|
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const documentColumns: GridColDef[] = [
|
const documentColumns: GridColDef[] = [
|
||||||
@@ -122,6 +183,8 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
const numSaves = properties.reduce((accum, currProperty) => {
|
const numSaves = properties.reduce((accum, currProperty) => {
|
||||||
return accum + currProperty.saves;
|
return accum + currProperty.saves;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const savedPropertiesCardLength: number = savedProperties.length === 0 ? 6 : 12;
|
||||||
|
const documentsCardLength: number = documents.length === 0 ? 6 : 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid
|
<Grid
|
||||||
@@ -201,11 +264,38 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12}>
|
|
||||||
<Divider sx={{ my: 2 }} />
|
{account.tier === 'basic' && (
|
||||||
</Grid>
|
<Grid xs={12} md={4}>
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column">
|
||||||
|
<Typography variant="h4">Upgrade your account</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Unlock premium features to get more features and sell faster
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CardActionArea>
|
||||||
|
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
<Button>Learn More</Button>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Properties */}
|
{/* Properties */}
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{properties.length > 0 && (
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
{properties.map((item) => (
|
{properties.map((item) => (
|
||||||
<Grid xs={12} key={item.id}>
|
<Grid xs={12} key={item.id}>
|
||||||
<PropertyDetailCard
|
<PropertyDetailCard
|
||||||
@@ -221,80 +311,72 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12} md={6}>
|
|
||||||
|
<Grid xs={12} md={documentsCardLength}>
|
||||||
<Card sx={{ display: 'flex' }}>
|
<Card sx={{ display: 'flex' }}>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
<Typography variant="h4">Documents Requiring Attention</Typography>
|
<Typography variant="h4">Documents Requiring Attention</Typography>
|
||||||
<Typography variant="caption">something</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{documents.length === 0 ? (
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<Typography variant="caption">
|
||||||
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
|
There are no documents that require your attention at this point
|
||||||
</CardContent>
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<DataGrid getRowHeight={() => 70} rows={DocumentRows} columns={documentColumns} />
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid xs={12} md={6}>
|
<Grid xs={12} md={savedPropertiesCardLength}>
|
||||||
<Card>
|
<Card>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
<Typography variant="h4">Video Progress</Typography>
|
<Stack direction="column">
|
||||||
<Typography variant="caption">
|
<Typography variant="h4">Saved Properties</Typography>
|
||||||
Complete our FSBO training to maximize your sale potential
|
<Typography variant="caption">Keep track of the properties you have saved</Typography>
|
||||||
</Typography>
|
</Stack>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EducationInfoCards />
|
<SavedPropertiesTable savedProperties={savedProperties} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12} md={4}>
|
<Grid xs={12} md={12}>
|
||||||
<Card>
|
{account.tier === 'premium' ? (
|
||||||
<Stack direction="column">
|
<Card>
|
||||||
<Typography variant="h4">Upgrade your account</Typography>
|
<Stack direction="column">
|
||||||
<Typography variant="caption">
|
<Stack direction="column">
|
||||||
Unlock premium features to get more features and sell faster
|
<Typography variant="h4">Video Progress</Typography>
|
||||||
</Typography>
|
<Typography variant="caption">
|
||||||
</Stack>
|
Complete our FSBO training to maximize your sale potential
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<CardActionArea>
|
<CardContent>
|
||||||
<Button variant="contained" component="label">
|
<EducationInfoCards />
|
||||||
Upgrade
|
</CardContent>
|
||||||
</Button>
|
</Stack>
|
||||||
<Button>Learn More</Button>
|
</Card>
|
||||||
</CardActionArea>
|
) : (
|
||||||
</Card>
|
<Card>
|
||||||
</Grid>
|
<Stack direction="column">
|
||||||
{/* <Grid xs={12} md={4}>
|
<Typography variant="h4">Video Progress</Typography>
|
||||||
<NotificationInfoCard />
|
<Typography variant="caption">
|
||||||
</Grid>
|
Upgrade to get access to FSBO educational videos
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Grid xs={12} md={8}>
|
<CardActionArea>
|
||||||
<EducationInfoCards />
|
<Button variant="contained" component="label" onClick={() => navigate('/upgrade/')}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
<Button>Learn More</Button>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12}>
|
|
||||||
<SaleInfoCards />
|
|
||||||
</Grid>
|
|
||||||
<Grid xs={12} md={8}>
|
|
||||||
<Revenue />
|
|
||||||
</Grid>
|
|
||||||
<Grid xs={12} md={4}>
|
|
||||||
<WebsiteVisitors />
|
|
||||||
</Grid>
|
|
||||||
<Grid xs={12} lg={8}>
|
|
||||||
<TopSellingProduct />
|
|
||||||
</Grid>
|
|
||||||
<Grid xs={12} lg={4}>
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', sm: 'row', lg: 'column' }}
|
|
||||||
gap={3.75}
|
|
||||||
height={1}
|
|
||||||
width={1}
|
|
||||||
>
|
|
||||||
<NewCustomers />
|
|
||||||
<BuyersProfile />
|
|
||||||
</Stack>
|
|
||||||
</Grid> */}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Listings Summary Card */}
|
{/* Listings Summary Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -83,7 +83,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* New Offers Card */}
|
{/* New Offers Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -114,7 +114,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Upcoming Showings Card */}
|
{/* Upcoming Showings Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -148,7 +148,7 @@ const RealEstateAgentDashboard = ({ account }: DashboardProps): ReactElement =>
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Example of other cards */}
|
{/* Example of other cards */}
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h5" gutterBottom>
|
<Typography variant="h5" gutterBottom>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
|
|
||||||
|
interface SavedPropertiesTableProps {
|
||||||
|
savedProperties: PropertiesAPI[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SavedPropertiesTable: React.FC<SavedPropertiesTableProps> = ({ savedProperties }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getUpcomingOpenHouseDate = (property: PropertiesAPI): string => {
|
||||||
|
if (!property.open_houses || property.open_houses.length === 0) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const futureOpenHouses = property.open_houses.filter(
|
||||||
|
(openHouse) => new Date(openHouse.listed_date) >= today,
|
||||||
|
);
|
||||||
|
if (futureOpenHouses.length > 0) {
|
||||||
|
// Sort to get the soonest upcoming one
|
||||||
|
futureOpenHouses.sort(
|
||||||
|
(a, b) => new Date(a.listed_date).getTime() - new Date(b.listed_date).getTime(),
|
||||||
|
);
|
||||||
|
// Format the date for display
|
||||||
|
return new Date(futureOpenHouses[0].listed_date).toLocaleDateString();
|
||||||
|
}
|
||||||
|
return 'No upcoming dates';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (savedProperties.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="h6" align="center">
|
||||||
|
You don't have any saved properties.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} sx={{ mt: 4 }}>
|
||||||
|
<Table sx={{ minWidth: 650 }} aria-label="saved properties table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Street Address</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Price</TableCell>
|
||||||
|
<TableCell>Upcoming Open House</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{savedProperties.map((property) => {
|
||||||
|
const displayPrice = property.listed_price
|
||||||
|
? `$${property.listed_price}`
|
||||||
|
: `$${property.market_value}`;
|
||||||
|
|
||||||
|
const openHouseDate = getUpcomingOpenHouseDate(property);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={property.id}>
|
||||||
|
<TableCell>{property.street}</TableCell>
|
||||||
|
<TableCell>{property.property_status}</TableCell>
|
||||||
|
<TableCell>{displayPrice}</TableCell>
|
||||||
|
<TableCell>{openHouseDate}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/property/${property.id}/?search=1`)}
|
||||||
|
>
|
||||||
|
View Listing
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SavedPropertiesTable;
|
||||||
@@ -165,7 +165,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Views Card */}
|
{/* Views Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -189,7 +189,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Bids Card */}
|
{/* Bids Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -240,7 +240,7 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Conversations Card */}
|
{/* Conversations Card */}
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
@@ -278,10 +278,10 @@ const VendorDashboard = ({ account }: DashboardProps): ReactElement => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>*/}
|
</Grid>*/}
|
||||||
<Grid xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={12}>
|
<Grid size={{ xs: 12, md: 12 }}>
|
||||||
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
|
<VendorDetail vendor={vendorItem as VendorItem} showMessageBtn={false} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
|
||||||
|
import { PropertiesAPI, DocumentAPI, UserAPI, BidAPI } from 'types';
|
||||||
|
import AttorneyDocumentDialog from './Dialog/AttorneyDocumentDialog';
|
||||||
|
import PropertyOwnerDocumentDialog from './Dialog/PropertyOwnerDocumentDialog';
|
||||||
|
import VendorDocumentDialog from './Dialog/VendorDocumentDialog';
|
||||||
|
|
||||||
|
export type DocumentDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
bids: BidAPI[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddDocumentDialogProps = DocumentDialogProps & {
|
||||||
|
account: UserAPI;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddDocumentDialog = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
properties,
|
||||||
|
bids,
|
||||||
|
account,
|
||||||
|
}: AddDocumentDialogProps): ReactElement => {
|
||||||
|
if (account.user_type === 'property_owner') {
|
||||||
|
return (
|
||||||
|
<PropertyOwnerDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.user_type === 'vendor') {
|
||||||
|
return (
|
||||||
|
<VendorDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.user_type === 'attorney') {
|
||||||
|
return (
|
||||||
|
<AttorneyDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<Typography>Woops, we have encountered an error</Typography>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddDocumentDialog;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
const AttorneyDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
|
||||||
|
const [documentType, setDocumentType] = useState<string>('');
|
||||||
|
|
||||||
|
const getDialogContent = (document_type: string) => {
|
||||||
|
if (document_type === 'offer') {
|
||||||
|
} else {
|
||||||
|
return <Typography>Please select a document type</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<DialogTitle>Create a new document</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Select
|
||||||
|
placeholder="Select Document Type"
|
||||||
|
onChange={(e) => setDocumentType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="Select Document Type">Select Document Type</MenuItem>
|
||||||
|
<MenuItem value="attorney_engagment_letter">Attorney Engagment Letter</MenuItem>
|
||||||
|
<MenuItem value="sellers_disclosure">Seller Disclosure</MenuItem>
|
||||||
|
<MenuItem value="closing_document">Closing Document</MenuItem>
|
||||||
|
<MenuItem value="title_report">Title Report</MenuItem>
|
||||||
|
<MenuItem value="closing_disclosure">Closing Disclosure</MenuItem>
|
||||||
|
</Select>
|
||||||
|
{getDialogContent(documentType)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttorneyDocumentDialog;
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { formatCurrency } from 'utils';
|
||||||
|
import { BidAPI, PropertiesAPI, VendorAPI } from 'types';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
export interface HomeImprovementReceiptData {
|
||||||
|
propertyId: number;
|
||||||
|
vendorId: number | null;
|
||||||
|
dateOfWork: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
receiptFile?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeImprovementReceiptDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, receipt: HomeImprovementReceiptData) => void;
|
||||||
|
properties: PropertiesAPI[]; // The specific property for which the receipt is being submitted
|
||||||
|
vendors: VendorAPI[]; // List of available vendors for autocomplete
|
||||||
|
bids: BidAPI[];
|
||||||
|
homeImprovmentErrors: { [key: string]: string };
|
||||||
|
loadingVendors?: boolean; // Optional: indicate if vendors are still loading
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeImprovementReciptDialogContent = forwardRef<
|
||||||
|
HomeImprovementReceiptDialogContentHandle,
|
||||||
|
HomeImprovementReceiptDialogProps
|
||||||
|
>(({ onSubmit, properties, vendors, bids, homeImprovmentErrors, loadingVendors = false }, ref) => {
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
const [selectedBid, setSelectedBid] = useState<BidAPI | null>(null);
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<VendorAPI | null>(null);
|
||||||
|
const [dateOfWork, setDateOfWork] = useState<string>('');
|
||||||
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const [cost, setCost] = useState<string>('');
|
||||||
|
// const [receiptFile, setReceiptFile] = useState<File | null>(null); // For file upload
|
||||||
|
|
||||||
|
// Reset form when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedVendor(null);
|
||||||
|
setDateOfWork('');
|
||||||
|
setDescription('');
|
||||||
|
setCost('');
|
||||||
|
// setReceiptFile(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
// Basic validation
|
||||||
|
if (!selectedProperty) {
|
||||||
|
return;
|
||||||
|
} else if (!selectedProperty.id || !selectedVendor || !dateOfWork || !cost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiptData: HomeImprovementReceiptData = {
|
||||||
|
propertyId: selectedProperty?.id,
|
||||||
|
vendorId: selectedVendor.user.id,
|
||||||
|
dateOfWork,
|
||||||
|
description,
|
||||||
|
cost: parseFloat(cost),
|
||||||
|
// receiptFile, // Include if handling file upload
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit('contractor_recipt', receiptData);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(properties);
|
||||||
|
|
||||||
|
const getVendorOptionLabel = (option: VendorAPI) =>
|
||||||
|
`${option.business_name} (${option.business_type})`;
|
||||||
|
return (
|
||||||
|
<Grid size={{ xs: 12, md: 12 }}>
|
||||||
|
<Select
|
||||||
|
value={selectedProperty?.id || 'Pick a property'}
|
||||||
|
placeholder="Pick a property"
|
||||||
|
onChange={(e) => {
|
||||||
|
console.log(e.target.value);
|
||||||
|
console.log(properties);
|
||||||
|
console.log(properties.find((property) => property.id === Number(e.target.value)));
|
||||||
|
const foundProperty = properties.find(
|
||||||
|
(property) => property.id === Number(e.target.value),
|
||||||
|
);
|
||||||
|
setSelectedProperty(foundProperty);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<MenuItem value={property.id}>{property.address}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{selectedProperty && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
|
||||||
|
{selectedProperty.state} {selectedProperty.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
Record details of home improvements, maintenance, or repairs.
|
||||||
|
</Typography>
|
||||||
|
{bids.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={selectedBid}
|
||||||
|
label="Bid"
|
||||||
|
onChange={(e) => {
|
||||||
|
const foundBid = bids.find((bid) => bid.id === Number(e.target.value));
|
||||||
|
setSelectedBid(foundBid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bids.map((bid) => (
|
||||||
|
<MenuItem value={bid.id}>{bid.description}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={vendors}
|
||||||
|
getOptionLabel={getVendorOptionLabel}
|
||||||
|
loading={loadingVendors}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
setSelectedVendor(newValue);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Select Vendor"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<React.Fragment>
|
||||||
|
{loadingVendors ? (
|
||||||
|
<CircularProgress color="inherit" size={20} />
|
||||||
|
) : null}
|
||||||
|
{params.InputAdornments?.end}
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Date of Work"
|
||||||
|
type="date"
|
||||||
|
value={dateOfWork}
|
||||||
|
onChange={(e) => setDateOfWork(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Description of Work Performed"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText="e.g., Replaced water heater, Repaired leaky faucet, Painted living room."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Cost of Work"
|
||||||
|
type="number"
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{/* Uncomment and implement if you need file uploads */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mt: 1, mb: 0.5 }}>
|
||||||
|
Upload Receipt (Optional)
|
||||||
|
</Typography>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setReceiptFile(e.target.files ? e.target.files[0] : null)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography>There are no bids to put the recipt against</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HomeImprovementReciptDialogContent;
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { formatCurrency } from 'utils'; // Assuming this utility function is available
|
||||||
|
import { axiosInstance } from '../../../../../../axiosApi'; // Assuming axiosInstance is configured
|
||||||
|
import { PropertiesAPI } from 'types'; // Assuming PropertiesAPI is defined in your types file
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
// Define the shape of the offer data to be submitted
|
||||||
|
export interface OfferData {
|
||||||
|
propertyId: number | null;
|
||||||
|
offerPrice: number;
|
||||||
|
closingDate?: string;
|
||||||
|
closingDays?: number;
|
||||||
|
contingencies: string;
|
||||||
|
parent_offer?: OfferData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the handle for the ref that the parent can use to interact with this component
|
||||||
|
export interface OfferDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the type for properties passed down
|
||||||
|
type OfferPropertyType = {
|
||||||
|
address: string;
|
||||||
|
marketValue: string;
|
||||||
|
property_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface for props of OfferDialogContent
|
||||||
|
interface OfferFormDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, offer: OfferData) => void; // Callback function to send data to parent
|
||||||
|
offerErrors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const OfferDialogContent = forwardRef<OfferDialogContentHandle, OfferFormDialogProps>(
|
||||||
|
({ onSubmit, offerErrors }, ref) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
|
const [filteredProperties, setFilteredProperties] = useState<OfferPropertyType[]>([]);
|
||||||
|
const [loadingProperties, setLoadingProperties] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// State for form fields
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<OfferPropertyType | null>(null);
|
||||||
|
const [offerPrice, setOfferPrice] = useState<string>('');
|
||||||
|
const [closingDate, setClosingDate] = useState<string>('');
|
||||||
|
const [closingDays, setClosingDays] = useState<string>('30'); // Default to 30 days
|
||||||
|
const [contingencies, setContingencies] = useState<string>('');
|
||||||
|
|
||||||
|
// Mortgage Calculation States
|
||||||
|
const [loanInterestRate, setLoanInterestRate] = useState<string>('7.0'); // Annual percentage
|
||||||
|
const [loanTermYears, setLoanTermYears] = useState<string>('30'); // Years
|
||||||
|
const [downPaymentPercentage, setDownPaymentPercentage] = useState<string>('20'); // Percentage of offer price
|
||||||
|
|
||||||
|
const [estimatedMonthlyPayment, setEstimatedMonthlyPayment] = useState<string>('N/A');
|
||||||
|
|
||||||
|
// Handle input change for the Autocomplete component, fetching properties
|
||||||
|
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
setLoadingProperties(true);
|
||||||
|
try {
|
||||||
|
// Fetch properties based on search input
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get(
|
||||||
|
`/properties/?search=${newInputValue}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map API response to OfferPropertyType
|
||||||
|
const mappedResults: OfferPropertyType[] = data.map((item) => ({
|
||||||
|
address: item.address,
|
||||||
|
marketValue: item.market_value,
|
||||||
|
property_id: item.id,
|
||||||
|
}));
|
||||||
|
setFilteredProperties(mappedResults);
|
||||||
|
setOptions(mappedResults.map((item) => item.address)); // Set options for Autocomplete
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties:', error);
|
||||||
|
setOptions([]);
|
||||||
|
setFilteredProperties([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingProperties(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
setFilteredProperties([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect to recalculate mortgage whenever relevant inputs change
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateMortgage = () => {
|
||||||
|
const price = parseFloat(offerPrice);
|
||||||
|
const interestRate = parseFloat(loanInterestRate);
|
||||||
|
const termYears = parseFloat(loanTermYears);
|
||||||
|
const downPaymentPct = parseFloat(downPaymentPercentage);
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (
|
||||||
|
isNaN(price) ||
|
||||||
|
price <= 0 ||
|
||||||
|
isNaN(interestRate) ||
|
||||||
|
isNaN(termYears) ||
|
||||||
|
termYears <= 0 ||
|
||||||
|
isNaN(downPaymentPct)
|
||||||
|
) {
|
||||||
|
setEstimatedMonthlyPayment('N/A');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downPaymentAmount = price * (downPaymentPct / 100);
|
||||||
|
const principalLoanAmount = price - downPaymentAmount;
|
||||||
|
|
||||||
|
if (principalLoanAmount <= 0) {
|
||||||
|
setEstimatedMonthlyPayment(formatCurrency(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyInterestRate = interestRate / 100 / 12;
|
||||||
|
const numberOfPayments = termYears * 12;
|
||||||
|
|
||||||
|
let monthlyPayment = 0;
|
||||||
|
if (monthlyInterestRate === 0) {
|
||||||
|
// Handle zero interest rate scenario
|
||||||
|
monthlyPayment = principalLoanAmount / numberOfPayments;
|
||||||
|
} else {
|
||||||
|
// Standard mortgage formula
|
||||||
|
monthlyPayment =
|
||||||
|
(principalLoanAmount *
|
||||||
|
monthlyInterestRate *
|
||||||
|
Math.pow(1 + monthlyInterestRate, numberOfPayments)) /
|
||||||
|
(Math.pow(1 + monthlyInterestRate, numberOfPayments) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEstimatedMonthlyPayment(formatCurrency(monthlyPayment));
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateMortgage();
|
||||||
|
}, [offerPrice, loanInterestRate, loanTermYears, downPaymentPercentage]);
|
||||||
|
|
||||||
|
// Function to handle form submission
|
||||||
|
const submitForm = () => {
|
||||||
|
// Basic validation
|
||||||
|
if (!selectedProperty || !offerPrice) {
|
||||||
|
console.error('Validation Error: Please select a property and enter an offer price.');
|
||||||
|
// In a real app, you would display a user-friendly error message here (e.g., Snackbar)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the offer data object
|
||||||
|
const formData: OfferData = {
|
||||||
|
propertyId: selectedProperty.property_id,
|
||||||
|
property: selectedProperty.property_id,
|
||||||
|
offer_price: parseFloat(offerPrice),
|
||||||
|
closing_date: closingDate || undefined,
|
||||||
|
closing_days: closingDays ? parseInt(closingDays) : undefined,
|
||||||
|
contingencies,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the onSubmit prop to pass data to the parent
|
||||||
|
onSubmit('offer', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose the submitForm function via useImperativeHandle so parent can call it
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(selectedProperty);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Offer Form Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 7 }}>
|
||||||
|
<Autocomplete
|
||||||
|
value={selectedProperty?.address || null} // Display selected property address or null
|
||||||
|
options={options}
|
||||||
|
loading={loadingProperties}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
// Find the selected property object from filteredProperties
|
||||||
|
const selectedAddr = filteredProperties.find((item) => item.address === newValue);
|
||||||
|
setSelectedProperty(selectedAddr || null); // Set selected property or null
|
||||||
|
}}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the address to search for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Select Property"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<React.Fragment>
|
||||||
|
{loadingProperties ? <CircularProgress color="inherit" size={20} /> : null}
|
||||||
|
{params.InputProps.endAdornment} {/* Pass existing endAdornment */}
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Offer Price"
|
||||||
|
type="number"
|
||||||
|
value={offerPrice}
|
||||||
|
onChange={(e) => setOfferPrice(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
helperText={offerErrors.offer_price}
|
||||||
|
error={!!offerErrors.offer_price}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Closing Date (Optional)"
|
||||||
|
type="date"
|
||||||
|
value={closingDate}
|
||||||
|
onChange={(e) => setClosingDate(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
helperText={offerErrors.closing_date}
|
||||||
|
error={!!offerErrors.closing_date}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 1 }}>
|
||||||
|
OR
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Closing Duration (Days)"
|
||||||
|
type="number"
|
||||||
|
value={closingDays}
|
||||||
|
onChange={(e) => setClosingDays(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={offerErrors.closing_days}
|
||||||
|
error={!!offerErrors.closing_days}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Other Contingencies (e.g., inspection, financing)"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={contingencies}
|
||||||
|
onChange={(e) => setContingencies(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={offerErrors.contingencies}
|
||||||
|
error={!!offerErrors.contingencies}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Estimated Mortgage Payment Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 5 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Estimated Mortgage Payment
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Down Payment (%)"
|
||||||
|
type="number"
|
||||||
|
value={downPaymentPercentage}
|
||||||
|
onChange={(e) => setDownPaymentPercentage(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Annual Interest Rate (%)"
|
||||||
|
type="number"
|
||||||
|
value={loanInterestRate}
|
||||||
|
onChange={(e) => setLoanInterestRate(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Loan Term (Years)"
|
||||||
|
type="number"
|
||||||
|
value={loanTermYears}
|
||||||
|
onChange={(e) => setLoanTermYears(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
p: 2,
|
||||||
|
border: '1px dashed #bdbdbd',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1">Estimated Monthly Payment (P&I):</Typography>
|
||||||
|
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{estimatedMonthlyPayment}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
*This is an estimate for Principal & Interest only. It does not include taxes,
|
||||||
|
insurance, or HOA fees.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default OfferDialogContent;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import React, { ReactElement, useState, useRef } from 'react';
|
||||||
|
import OfferDialogContent, { OfferDialogContentHandle, OfferData } from './OfferDialogContent'; // Import the handle and data types
|
||||||
|
import SellerDisclousureDialogContent, {
|
||||||
|
SellerDisclousureDialogContentHandle,
|
||||||
|
SellerDisclousureData,
|
||||||
|
} from './SellerDisclousureDialogContent';
|
||||||
|
import HomeImprovementReciptDialogContent, {
|
||||||
|
HomeImprovementReceiptData,
|
||||||
|
HomeImprovementReceiptDialogContentHandle,
|
||||||
|
} from './HomeImprovementReciptDialogContent';
|
||||||
|
import { axiosInstance } from '../../../../../../axiosApi';
|
||||||
|
// Assuming BidAPI, PropertiesAPI, VendorAPI are defined in types or available
|
||||||
|
|
||||||
|
export type PropertyOwnerDocumentType = 'offer' | 'seller_disclosure' | 'home_improvement_receipt';
|
||||||
|
|
||||||
|
// Custom message box component for user feedback
|
||||||
|
const MessageBox = ({ message, onClose }: { message: string; onClose: () => void }) => (
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
<DialogTitle>Error</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>{message}</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>OK</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PropertyOwnerDocumentDialog = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
properties,
|
||||||
|
bids,
|
||||||
|
}: DocumentDialogProps): ReactElement => {
|
||||||
|
const [documentType, setDocumentType] = useState<PropertyOwnerDocumentType | null>(null);
|
||||||
|
const offerDialogRef = useRef<OfferDialogContentHandle>(null); // Ref for OfferDialogContent
|
||||||
|
const sellerDisclosureRef = useRef<SellerDisclousureDialogContentHandle>(null);
|
||||||
|
const homeImprovementRef = useRef<HomeImprovementReceiptDialogContentHandle>(null);
|
||||||
|
const [submittedOfferData, setSubmittedOfferData] = useState<OfferData | null>(null); // State to hold submitted data
|
||||||
|
const [showError, setShowError] = useState<boolean>(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const [homeImprovmentErrors, setHomeImprovementErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const [sellerDisclosureErrors, setSellerDisclosureErrors] = useState<{ [key: string]: string }>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [offerErrors, setOfferErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// Callback function to receive data from OfferDialogContent
|
||||||
|
const handleOfferSubmit = async (
|
||||||
|
docType: PropertyOwnerDocumentType,
|
||||||
|
data: OfferData | SellerDisclousureData | HomeImprovementReceiptData,
|
||||||
|
) => {
|
||||||
|
if (docType === 'offer') {
|
||||||
|
console.log(data);
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/documents/upload/', {
|
||||||
|
document_type: docType,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setOfferErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.response.data.errors);
|
||||||
|
setOfferErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
} else if (docType === 'seller_disclosure') {
|
||||||
|
console.log(data);
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/documents/upload/', {
|
||||||
|
document_type: docType,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
if (response.error) {
|
||||||
|
setSellerDisclosureErrors(response.error);
|
||||||
|
}
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.response.data.errors);
|
||||||
|
setSellerDisclosureErrors(error.response.data.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SellerDisclousureData Data Received in PropertyOwnerDocumentDialog:', data);
|
||||||
|
} else if (docType === 'home_improvement_receipt') {
|
||||||
|
console.log('HomeImprovementReceiptData Data Received in PropertyOwnerDocumentDialog:', data);
|
||||||
|
closeDialog(); // Close the dialog after successful submission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here you would typically send this 'data' to your backend API
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to render the appropriate dialog content based on document type
|
||||||
|
const getDialogContent = (document_type: PropertyOwnerDocumentType | null): ReactElement => {
|
||||||
|
if (document_type === 'offer') {
|
||||||
|
return (
|
||||||
|
<OfferDialogContent
|
||||||
|
ref={offerDialogRef} // Pass the ref to the OfferDialogContent
|
||||||
|
onSubmit={handleOfferSubmit} // Pass the callback for submitted data
|
||||||
|
offerErrors={offerErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (document_type === 'seller_disclosure') {
|
||||||
|
// If SellerDisclousureDialogContent also needs to pass data, it would need
|
||||||
|
// a similar ref and onSubmit prop setup.
|
||||||
|
return (
|
||||||
|
<SellerDisclousureDialogContent
|
||||||
|
ref={sellerDisclosureRef}
|
||||||
|
onSubmit={handleOfferSubmit}
|
||||||
|
properties={properties} // Pass properties down
|
||||||
|
sellerDisclosureErrors={sellerDisclosureErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (document_type === 'home_improvement_receipt') {
|
||||||
|
// Similar to offer, if HomeImprovementReciptDialogContent needs to pass data.
|
||||||
|
return (
|
||||||
|
<HomeImprovementReciptDialogContent
|
||||||
|
ref={homeImprovementRef} // Example: needs its own ref
|
||||||
|
onSubmit={handleOfferSubmit} // Example: needs its own handler
|
||||||
|
properties={properties}
|
||||||
|
vendors={[]}
|
||||||
|
bids={bids}
|
||||||
|
homeImprovmentErrors={homeImprovmentErrors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Typography>Please select a document type</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the currently active dialog content as a React element
|
||||||
|
const dialogContentElement = getDialogContent(documentType);
|
||||||
|
|
||||||
|
// Function to handle the "Create" button click in the parent dialog
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (documentType === 'offer') {
|
||||||
|
if (offerDialogRef.current) {
|
||||||
|
// Trigger the submitForm method exposed by OfferDialogContent via the ref
|
||||||
|
offerDialogRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Offer dialog content not ready. Please select a document type.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else if (documentType === 'seller_disclosure') {
|
||||||
|
if (sellerDisclosureRef.current) {
|
||||||
|
sellerDisclosureRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Seller Disclosure submission not implemented yet.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else if (documentType === 'contractor_recipt') {
|
||||||
|
// Placeholder for home improvement receipt submission
|
||||||
|
if (homeImprovementRef.current) {
|
||||||
|
homeImprovementRef.current.submitForm();
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Home Improvement Recipt submission not implemented yet.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no document type is selected
|
||||||
|
setErrorMessage('Please select a document type before creating.');
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<DialogTitle>Create a new document</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Select
|
||||||
|
value={documentType || ''} // Control the value of the Select component
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value === 'offer' ||
|
||||||
|
e.target.value === 'seller_disclosure' ||
|
||||||
|
e.target.value === 'contractor_recipt'
|
||||||
|
) {
|
||||||
|
setDocumentType(e.target.value as PropertyOwnerDocumentType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
displayEmpty // Allows the placeholder to be displayed when value is empty
|
||||||
|
sx={{ mb: 2, minWidth: 200 }} // Add some margin-bottom for spacing
|
||||||
|
>
|
||||||
|
<MenuItem value="" disabled>
|
||||||
|
Select Document Type
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="offer">Offer</MenuItem>
|
||||||
|
<MenuItem value="seller_disclosure">Seller Disclosure</MenuItem>
|
||||||
|
<MenuItem value="contractor_recipt">Home Improvement Recipt</MenuItem>
|
||||||
|
</Select>
|
||||||
|
{dialogContentElement} {/* Render the selected dialog content */}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="primary" sx={{ ml: 'auto' }} onClick={handleCreate}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
{/* Render the message box if there's an error */}
|
||||||
|
{showError && <MessageBox message={errorMessage} onClose={() => setShowError(false)} />}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyOwnerDocumentDialog;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement, forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
import { PropertyOwnerDocumentType } from './PropertyOwnerDocumentDialog';
|
||||||
|
|
||||||
|
export interface SellerDisclousureData {
|
||||||
|
generalDefects: string;
|
||||||
|
roofCondition: string;
|
||||||
|
roofAge: string;
|
||||||
|
knownRoofLeaks: boolean;
|
||||||
|
plumbingIssues: string;
|
||||||
|
electricalIssues: string;
|
||||||
|
hvacCondition: string;
|
||||||
|
hvacAge: string;
|
||||||
|
knownLeadPaint: boolean;
|
||||||
|
knownAsbestos: boolean;
|
||||||
|
knownRadon: boolean;
|
||||||
|
pastWaterDamage: string;
|
||||||
|
structuralIssues: string;
|
||||||
|
neighborhoodNuisances: string;
|
||||||
|
propertyLineDisputes: string;
|
||||||
|
appliancesIncluded: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the handle for the ref that the parent can use to interact with this component
|
||||||
|
export interface SellerDisclousureDialogContentHandle {
|
||||||
|
submitForm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for props of OfferDialogContent
|
||||||
|
interface SellerDisclousureFormDialogProps {
|
||||||
|
onSubmit: (docType: PropertyOwnerDocumentType, disclosure: SellerDisclousureData) => void; // Callback function to send data to parent
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
sellerDisclosureErrors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerDisclousureDialogContent = forwardRef<
|
||||||
|
SellerDisclousureDialogContentHandle,
|
||||||
|
SellerDisclousureFormDialogProps
|
||||||
|
>(({ onSubmit, properties, sellerDisclosureErrors }, ref) => {
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
|
||||||
|
const [generalDefects, setGeneralDefects] = useState<string>('');
|
||||||
|
const [roofCondition, setRoofCondition] = useState<string>('');
|
||||||
|
const [roofAge, setRoofAge] = useState<string>('');
|
||||||
|
const [knownRoofLeaks, setKnownRoofLeaks] = useState<boolean>(false);
|
||||||
|
const [plumbingIssues, setPlumbingIssues] = useState<string>('');
|
||||||
|
const [electricalIssues, setElectricalIssues] = useState<string>('');
|
||||||
|
const [hvacCondition, setHvacCondition] = useState<string>('');
|
||||||
|
const [hvacAge, setHvacAge] = useState<string>('');
|
||||||
|
const [knownLeadPaint, setKnownLeadPaint] = useState<boolean>(false);
|
||||||
|
const [knownAsbestos, setKnownAsbestos] = useState<boolean>(false);
|
||||||
|
const [knownRadon, setKnownRadon] = useState<boolean>(false);
|
||||||
|
const [pastWaterDamage, setPastWaterDamage] = useState<string>('');
|
||||||
|
const [structuralIssues, setStructuralIssues] = useState<string>('');
|
||||||
|
const [neighborhoodNuisances, setNeighborhoodNuisances] = useState<string>('');
|
||||||
|
const [propertyLineDisputes, setPropertyLineDisputes] = useState<string>('');
|
||||||
|
const [appliancesIncluded, setAppliancesIncluded] = useState<string>('');
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
const formData: SellerDisclousureData = {
|
||||||
|
property: selectedProperty?.id,
|
||||||
|
general_defects: generalDefects,
|
||||||
|
roof_condition: roofCondition,
|
||||||
|
roof_age: roofAge,
|
||||||
|
known_roof_leaks: knownRoofLeaks,
|
||||||
|
plumbing_issues: plumbingIssues,
|
||||||
|
electrical_issues: electricalIssues,
|
||||||
|
hvac_condition: hvacCondition,
|
||||||
|
hvac_age: hvacAge,
|
||||||
|
known_lead_paint: knownLeadPaint,
|
||||||
|
known_asbestos: knownAsbestos,
|
||||||
|
known_radon: knownRadon,
|
||||||
|
past_water_damage: pastWaterDamage,
|
||||||
|
structural_issues: structuralIssues,
|
||||||
|
neighborhood_nuisances: neighborhoodNuisances,
|
||||||
|
property_line_disputes: propertyLineDisputes,
|
||||||
|
appliances_included: appliancesIncluded,
|
||||||
|
};
|
||||||
|
onSubmit('seller_disclosure', formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(sellerDisclosureErrors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={selectedProperty?.id || ''}
|
||||||
|
label="Property"
|
||||||
|
onChange={(e) => {
|
||||||
|
const foundProperty = properties.find(
|
||||||
|
(property) => property.id === Number(e.target.value),
|
||||||
|
);
|
||||||
|
setSelectedProperty(foundProperty);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map((property) => (
|
||||||
|
<MenuItem value={property.id}>{property.address}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{selectedProperty && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
For Property: {selectedProperty.address}, {selectedProperty.city},{' '}
|
||||||
|
{selectedProperty.state} {selectedProperty.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
Please disclose any known material defects or information about the property. Honesty is
|
||||||
|
crucial to avoid potential legal issues.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="General Known Defects/Issues"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={generalDefects}
|
||||||
|
onChange={(e) => setGeneralDefects(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={`Describe any general issues not covered elsewhere (e.g., drainage problems, pest infestations). ${sellerDisclosureErrors.general_defects}`}
|
||||||
|
error={!!sellerDisclosureErrors.general_defects}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Property Systems Section */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Roof Information
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Roof Condition"
|
||||||
|
value={roofCondition}
|
||||||
|
onChange={(e) => setRoofCondition(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.roof_condition}
|
||||||
|
helperText={sellerDisclosureErrors.roof_condition}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Approximate Roof Age (Years)"
|
||||||
|
type="number"
|
||||||
|
value={roofAge}
|
||||||
|
onChange={(e) => setRoofAge(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.roof_age}
|
||||||
|
helperText={sellerDisclosureErrors.roof_age}
|
||||||
|
/>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_roof_leaks}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownRoofLeaks}
|
||||||
|
onChange={(e) => setKnownRoofLeaks(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Past or Present Roof Leaks?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_roof_leaks}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Major Systems
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Plumbing System Issues"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={plumbingIssues}
|
||||||
|
onChange={(e) => setPlumbingIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.plumbing_issues}
|
||||||
|
helperText={sellerDisclosureErrors.plumbing_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Electrical System Issues"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={electricalIssues}
|
||||||
|
onChange={(e) => setElectricalIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.electrical_issues}
|
||||||
|
helperText={sellerDisclosureErrors.electrical_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="HVAC (Heating, Ventilation, Air Conditioning) Condition"
|
||||||
|
value={hvacCondition}
|
||||||
|
onChange={(e) => setHvacCondition(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.hvac_condition}
|
||||||
|
helperText={sellerDisclosureErrors.hvac_condition}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Approximate HVAC Age (Years)"
|
||||||
|
type="number"
|
||||||
|
value={hvacAge}
|
||||||
|
onChange={(e) => setHvacAge(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.hvac_age}
|
||||||
|
helperText={sellerDisclosureErrors.hvac_age}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Environmental & Other Issues */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Environmental & Other Issues
|
||||||
|
</Typography>
|
||||||
|
<FormGroup row>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_lead_paint}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownLeadPaint}
|
||||||
|
onChange={(e) => setKnownLeadPaint(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Lead-Based Paint?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_lead_paint}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_asbestos}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownAsbestos}
|
||||||
|
onChange={(e) => setKnownAsbestos(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Asbestos?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_asbestos}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl error={!!sellerDisclosureErrors.known_radon}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={knownRadon}
|
||||||
|
onChange={(e) => setKnownRadon(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Known Radon?"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{sellerDisclosureErrors.known_radon}</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
<TextField
|
||||||
|
label="Past or Present Water Damage"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={pastWaterDamage}
|
||||||
|
onChange={(e) => setPastWaterDamage(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText={`Describe location, cause, and remediation if applicable. ${sellerDisclosureErrors.past_water_damage}`}
|
||||||
|
error={!!sellerDisclosureErrors.past_water_damage}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Structural Issues (e.g., foundation, walls)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={structuralIssues}
|
||||||
|
onChange={(e) => setStructuralIssues(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.structural_issues}
|
||||||
|
helperText={sellerDisclosureErrors.structural_issues}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Neighborhood Nuisances (e.g., excessive noise, odors)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={neighborhoodNuisances}
|
||||||
|
onChange={(e) => setNeighborhoodNuisances(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.neighborhood_nuisances}
|
||||||
|
helperText={sellerDisclosureErrors.neighborhood_nuisances}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Property Line Disputes (Past or Present)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={propertyLineDisputes}
|
||||||
|
onChange={(e) => setPropertyLineDisputes(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
error={!!sellerDisclosureErrors.past_water_damage}
|
||||||
|
helperText={sellerDisclosureErrors.past_water_damage}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Appliances Included */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Appliances Included in Sale
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="List all appliances included"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={appliancesIncluded}
|
||||||
|
onChange={(e) => setAppliancesIncluded(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
placeholder="e.g., Refrigerator, Washer, Dryer, Dishwasher..."
|
||||||
|
error={!!sellerDisclosureErrors.appliances_included}
|
||||||
|
helperText={sellerDisclosureErrors.appliances_included}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{ mt: 3, p: 2, border: '1px dashed #bdbdbd', borderRadius: 1, bgcolor: '#fffde7' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
By submitting this form, you acknowledge that the information provided is accurate to
|
||||||
|
the best of your knowledge and belief.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SellerDisclousureDialogContent;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Dialog, Typography } from '@mui/material';
|
||||||
|
import { DocumentDialogProps } from '../AddDocumentDialog';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
const VendorDocumentDialog = ({ showDialog, closeDialog }: DocumentDialogProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog}>
|
||||||
|
<Typography>Show the Vendor dialog</Typography>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDocumentDialog;
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// src/components/DocumentManager.tsx
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Container,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { BidAPI, DocumentAPI, PropertiesAPI } from 'types';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
|
|
||||||
|
import ArticleIcon from '@mui/icons-material/Article';
|
||||||
|
import { formatTimestamp } from 'utils';
|
||||||
|
import AddDocumentDialog from './AddDocumentDialog';
|
||||||
|
import SellerDisclosureDisplay from './SellerDisclosureDisplay';
|
||||||
|
import { PropertyOwnerDocumentType } from './Dialog/PropertyOwnerDocumentDialog';
|
||||||
|
import HomeImprovementReceiptDisplay from './HomeImprovementReceiptDisplay';
|
||||||
|
import OfferDisplay from './OfferDisplay';
|
||||||
|
import OfferNegotiationHistory from './OfferNegotiationHistory';
|
||||||
|
|
||||||
|
interface DocumentManagerProps {}
|
||||||
|
|
||||||
|
const getDocumentTitle = (docType: PropertyOwnerDocumentType) => {
|
||||||
|
if (docType === 'seller_disclosure') {
|
||||||
|
return 'Seller Disclosure';
|
||||||
|
} else if (docType === 'offer_letter') {
|
||||||
|
return 'Offer';
|
||||||
|
} else if (docType === 'home_improvement_receipt') {
|
||||||
|
return 'Home Improvement Receipt';
|
||||||
|
} else {
|
||||||
|
return docType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMyTypeDocument = (upload_by: number, account_id: number, document_type: string) => {
|
||||||
|
console.log(upload_by, account_id, document_type);
|
||||||
|
if (document_type === 'offer_letter') {
|
||||||
|
return !(upload_by === account_id);
|
||||||
|
} else if (document_type === 'seller_disclosure') {
|
||||||
|
return upload_by === account_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentManager: React.FC<DocumentManagerProps> = ({}) => {
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [documents, setDocuments] = useState<DocumentAPI[]>([]);
|
||||||
|
const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [bids, setBids] = useState<BidAPI[]>([]);
|
||||||
|
const [selectedDocument, setSelectedDocument] = useState<DocumentAPI | null>(null);
|
||||||
|
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||||
|
const [selectedPropertyForDocument, setSelectedPropertyForDocument] =
|
||||||
|
useState<PropertiesAPI | null>(null);
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocuments = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<DocumentAPI[]> = await axiosInstance.get('/document/');
|
||||||
|
setDocuments(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch documents:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchProperties = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await axiosInstance.get('/properties/');
|
||||||
|
setProperties(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchBids = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<BidAPI[]> = await axiosInstance.get('/bids/');
|
||||||
|
setBids(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch properties');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDocuments();
|
||||||
|
fetchProperties();
|
||||||
|
fetchBids();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedDocumentId = searchParams.get('selectedDocument');
|
||||||
|
if (selectedDocumentId) {
|
||||||
|
fetchDocument(parseInt(selectedDocumentId, 10));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProperty = async () => {
|
||||||
|
console.log(account.id);
|
||||||
|
console.log(selectedDocument?.uploaded_by);
|
||||||
|
console.log(
|
||||||
|
isMyTypeDocument(selectedDocument?.uploaded_by, account.id, selectedDocument.document_type),
|
||||||
|
);
|
||||||
|
const url = isMyTypeDocument(
|
||||||
|
selectedDocument?.uploaded_by,
|
||||||
|
account.id,
|
||||||
|
selectedDocument.document_type,
|
||||||
|
)
|
||||||
|
? `/properties/${selectedDocument.property}/`
|
||||||
|
: `/properties/${selectedDocument.property}/?search=1`;
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||||
|
setSelectedPropertyForDocument(data);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
const other_url =
|
||||||
|
url === `/properties/${selectedDocument.property}/`
|
||||||
|
? `/properties/${selectedDocument.property}/?search=1`
|
||||||
|
: `/properties/${selectedDocument.property}/`;
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(other_url);
|
||||||
|
setSelectedPropertyForDocument(data);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProperty();
|
||||||
|
}, [selectedDocument]);
|
||||||
|
|
||||||
|
console.log(documents);
|
||||||
|
|
||||||
|
const fetchDocument = async (documentId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`);
|
||||||
|
if (response?.data) {
|
||||||
|
setSelectedDocument(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSelectedDocument(null);
|
||||||
|
console.error('Failed to fetch document:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentPaneComponent = (selectedDocument: DocumentAPI) => {
|
||||||
|
console.log(selectedDocument?.document_type);
|
||||||
|
if (!selectedDocument) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 3,
|
||||||
|
color: 'grey.500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArticleIcon sx={{ fontSize: 80, mb: 2 }} />
|
||||||
|
<Typography variant="h6">Select a document to view</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Click on a document from the left panel to get started.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'seller_disclosure') {
|
||||||
|
console.log(selectedDocument);
|
||||||
|
return (
|
||||||
|
<SellerDisclosureDisplay
|
||||||
|
disclosureData={selectedDocument.sub_document}
|
||||||
|
property={selectedPropertyForDocument}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'home_improvement_receipt') {
|
||||||
|
return (
|
||||||
|
<HomeImprovementReceiptDisplay
|
||||||
|
receiptData={selectedDocument.sub_document}
|
||||||
|
property={selectedDocument.property}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedDocument.document_type === 'offer_letter') {
|
||||||
|
return (
|
||||||
|
<OfferNegotiationHistory
|
||||||
|
property={selectedPropertyForDocument}
|
||||||
|
isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
|
||||||
|
offerData={selectedDocument}
|
||||||
|
/>
|
||||||
|
// <OfferDisplay
|
||||||
|
// offerData={selectedDocument.sub_document}
|
||||||
|
// property={selectedPropertyForDocument}
|
||||||
|
// isPropertyOwner={selectedDocument?.uploaded_by !== account?.id}
|
||||||
|
// documentId={selectedDocument.id}
|
||||||
|
// />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Typography>Not sure what this is</Typography>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accountLoading) {
|
||||||
|
return <DashboardLoading />;
|
||||||
|
} else if (!account) {
|
||||||
|
return <DashboardErrorPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="lg"
|
||||||
|
sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Grid container sx={{ height: '100%' }}>
|
||||||
|
{/* Left Panel: Document List */}
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, md: 4 }}
|
||||||
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
|
||||||
|
<Stack direction="row" sx={{ width: '100%' }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Documents
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 'auto', mb: 0 }}
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||||
|
<ArticleIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||||
|
<Typography>There are no documents yet.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
documents
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||||
|
)
|
||||||
|
.map((document) => (
|
||||||
|
<ListItem
|
||||||
|
key={document.id}
|
||||||
|
button
|
||||||
|
selected={selectedDocument?.id === document.id}
|
||||||
|
onClick={() => fetchDocument(document.id)}
|
||||||
|
sx={{ py: 1.5, px: 2 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{getDocumentTitle(document.document_type)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ flexGrow: 1, pr: 1 }}
|
||||||
|
>
|
||||||
|
{document.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{formatTimestamp(document.updated_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
{/* Right Panel: Offer Detail */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{getDocumentPaneComponent(selectedDocument)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<AddDocumentDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
account={account}
|
||||||
|
properties={properties}
|
||||||
|
bids={bids}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentManager;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Grid,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
};
|
||||||
|
business_name: string;
|
||||||
|
business_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptData {
|
||||||
|
propertyId: number;
|
||||||
|
vendor: Vendor; // Assuming the full vendor object is passed for display
|
||||||
|
dateOfWork: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
// receiptFile?: string; // Assume URL string for display
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeImprovementReceiptDisplayProps {
|
||||||
|
receiptData: HomeImprovementReceiptData;
|
||||||
|
property: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomeImprovementReceiptDisplay: React.FC<HomeImprovementReceiptDisplayProps> = ({
|
||||||
|
receiptData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
if (!receiptData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No receipt information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vendor, dateOfWork, description, cost } = receiptData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Home Improvement Receipt
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Vendor" secondary={vendor.business_name} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Vendor Type" secondary={vendor.business_type} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Date of Work"
|
||||||
|
secondary={dateOfWork ? new Date(dateOfWork).toLocaleDateString() : 'N/A'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Cost" secondary={formatCurrency(cost)} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Description of Work Performed
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{description || 'No description provided.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeImprovementReceiptDisplay;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
CardActions,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferData {
|
||||||
|
propertyId: number;
|
||||||
|
offer_price: number;
|
||||||
|
closing_date?: string;
|
||||||
|
closing_days?: number;
|
||||||
|
contingencies: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferDisplayProps {
|
||||||
|
offerData: OfferData;
|
||||||
|
property: Property;
|
||||||
|
isPropertyOwner: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
onCounter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OfferDisplay: React.FC<OfferDisplayProps> = ({
|
||||||
|
offerData,
|
||||||
|
property,
|
||||||
|
isPropertyOwner,
|
||||||
|
onAccept,
|
||||||
|
onReject,
|
||||||
|
onCounter,
|
||||||
|
}) => {
|
||||||
|
if (!offerData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No offer information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { offer_price, closing_date, closing_days, contingencies, status } = offerData;
|
||||||
|
|
||||||
|
const getClosingText = () => {
|
||||||
|
if (closing_days) {
|
||||||
|
return `${closing_days} days`;
|
||||||
|
}
|
||||||
|
if (closing_date) {
|
||||||
|
try {
|
||||||
|
const formattedDate = new Date(closing_date).toLocaleDateString();
|
||||||
|
return `By ${formattedDate}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid closingDate format:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Not specified';
|
||||||
|
};
|
||||||
|
|
||||||
|
const showActions =
|
||||||
|
(isPropertyOwner && (status === 'submitted' || status === 'pending')) ||
|
||||||
|
(!isPropertyOwner && status === 'countered');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Residential House Offer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.primary">
|
||||||
|
Status: {status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Offer Price" secondary={formatCurrency(offer_price)} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Closing Timeline" secondary={getClosingText()} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ p: 2, border: '1px dashed #e0e0e0', borderRadius: 1, my: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Other Contingencies
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{contingencies || 'No contingencies listed.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
{showActions && (
|
||||||
|
<CardActions>
|
||||||
|
<Button variant="contained" onClick={onAccept}>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCounter}>Counter</Button>
|
||||||
|
<Button onClick={onReject}>Reject</Button>
|
||||||
|
</CardActions>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OfferDisplay;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import OfferDisplay, { OfferData } from './OfferDisplay';
|
||||||
|
import { DocumentAPI } from 'types';
|
||||||
|
|
||||||
|
// Interfaces for consistency across components
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferNegotiationHistoryProps {
|
||||||
|
property: Property;
|
||||||
|
isPropertyOwner: boolean;
|
||||||
|
offerData: DocumentAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OfferNegotiationHistory: React.FC<OfferNegotiationHistoryProps> = ({
|
||||||
|
property,
|
||||||
|
isPropertyOwner,
|
||||||
|
offerData,
|
||||||
|
}) => {
|
||||||
|
const [offer, setOffer] = useState<OfferData>(offerData);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedAccordion, setExpandedAccordion] = useState<number | false>(false);
|
||||||
|
|
||||||
|
const [history, setHistory] = useState<OfferData[]>([]);
|
||||||
|
// This useEffect builds the negotiation history
|
||||||
|
useEffect(() => {
|
||||||
|
let currentOffer = offerData.sub_document;
|
||||||
|
const negotiationHistory: OfferData[] = [];
|
||||||
|
|
||||||
|
// Traverse the parent chain until there is no parent
|
||||||
|
while (currentOffer) {
|
||||||
|
negotiationHistory.push(currentOffer);
|
||||||
|
currentOffer = currentOffer.parent_offer;
|
||||||
|
}
|
||||||
|
console.log(negotiationHistory);
|
||||||
|
const reversedHistory = negotiationHistory.reverse();
|
||||||
|
|
||||||
|
setHistory(reversedHistory);
|
||||||
|
if (reversedHistory.length > 0) {
|
||||||
|
setExpandedAccordion(reversedHistory[reversedHistory.length - 1].id);
|
||||||
|
}
|
||||||
|
}, [offerData]);
|
||||||
|
|
||||||
|
const handleChange = (panelId: number) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||||
|
console.log(panelId, isExpanded);
|
||||||
|
setExpandedAccordion(isExpanded ? panelId : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// const fetchOffers = async () => {
|
||||||
|
// setLoading(true);
|
||||||
|
// setError(null);
|
||||||
|
// try {
|
||||||
|
// // Assuming a new endpoint or a modified one that returns all offers for a property.
|
||||||
|
// // You may need to create this in your Django views.
|
||||||
|
// const response = await axiosInstance.get(`/documents/retrieve/?docId=${property.id}/`);
|
||||||
|
// setOffers(response.data);
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error('Failed to fetch offers:', err);
|
||||||
|
// setError('Failed to load offers. Please try again later.');
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// console.log(property);
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (property) {
|
||||||
|
// fetchOffers();
|
||||||
|
// }
|
||||||
|
// }, [property]);
|
||||||
|
|
||||||
|
const handleOfferAction = async (
|
||||||
|
documentId: number,
|
||||||
|
action: 'accept' | 'reject' | 'counter',
|
||||||
|
newOfferData?: any,
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
document_id: documentId,
|
||||||
|
action: action,
|
||||||
|
...newOfferData, // Pass new offer data for a counter-offer
|
||||||
|
};
|
||||||
|
await axiosInstance.patch('/documents/retrieve/', payload);
|
||||||
|
//await fetchOffers(); // Re-fetch offers to see the updated status
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to perform action '${action}':`, err);
|
||||||
|
setError('Failed to update offer status. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (offers.length === 0) {
|
||||||
|
// return (
|
||||||
|
// <Box sx={{ p: 3 }}>
|
||||||
|
// <Typography variant="h6" color="text.secondary">
|
||||||
|
// No offers have been submitted for this property.
|
||||||
|
// </Typography>
|
||||||
|
// </Box>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ my: 4 }}>
|
||||||
|
<Typography variant="h4" component="h2" gutterBottom>
|
||||||
|
Negotiation History
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{history.length > 0 ? (
|
||||||
|
history.map((offer, index) => (
|
||||||
|
<Accordion
|
||||||
|
key={index}
|
||||||
|
expanded={expandedAccordion === index}
|
||||||
|
onChange={handleChange(index)}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography>
|
||||||
|
Offer - {offer.status.toUpperCase()} - ${offer.offer_price}
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<OfferDisplay
|
||||||
|
offerData={offer}
|
||||||
|
property={property}
|
||||||
|
isPropertyOwner={isPropertyOwner}
|
||||||
|
onAccept={() => handleOfferAction(offer.id, 'accept')}
|
||||||
|
onReject={() => handleOfferAction(offer.id, 'reject')}
|
||||||
|
onCounter={() => {
|
||||||
|
const newPrice = offer.offer_price * 0.95;
|
||||||
|
handleOfferAction(offer.id, 'counter', { offer_price: newPrice });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography>No negotiation history available.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OfferNegotiationHistory;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Reuse the interfaces from the SellerDisclosureDialog for consistency
|
||||||
|
interface Property {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SellerDisclosureData {
|
||||||
|
propertyId: number;
|
||||||
|
general_defects: string;
|
||||||
|
roof_condition: string;
|
||||||
|
roof_age: number | null;
|
||||||
|
known_roof_leaks: boolean;
|
||||||
|
plumbing_issues: string;
|
||||||
|
electrical_issues: string;
|
||||||
|
hvac_condition: string;
|
||||||
|
hvac_age: number | null;
|
||||||
|
known_lead_paint: boolean;
|
||||||
|
known_asbestos: boolean;
|
||||||
|
known_radon: boolean;
|
||||||
|
past_water_damage: string;
|
||||||
|
structural_issues: string;
|
||||||
|
neighborhood_nuisances: string;
|
||||||
|
property_line_disputes: string;
|
||||||
|
appliances_included: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SellerDisclosureDisplayProps {
|
||||||
|
disclosureData: SellerDisclosureData;
|
||||||
|
property: Property; // The property associated with this disclosure
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerDisclosureDisplay: React.FC<SellerDisclosureDisplayProps> = ({
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
console.log(disclosureData);
|
||||||
|
if (!disclosureData || !property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
No disclosure information available to display.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format boolean values
|
||||||
|
const formatBoolean = (value: boolean) => (value ? 'Yes' : 'No');
|
||||||
|
|
||||||
|
const {
|
||||||
|
general_defects,
|
||||||
|
roof_condition,
|
||||||
|
roof_age,
|
||||||
|
known_roof_leaks,
|
||||||
|
plumbing_issues,
|
||||||
|
electrical_issues,
|
||||||
|
hvac_condition,
|
||||||
|
hvac_age,
|
||||||
|
known_lead_paint,
|
||||||
|
known_asbestos,
|
||||||
|
known_radon,
|
||||||
|
past_water_damage,
|
||||||
|
structural_issues,
|
||||||
|
neighborhood_nuisances,
|
||||||
|
property_line_disputes,
|
||||||
|
appliances_included,
|
||||||
|
} = disclosureData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card elevation={3} sx={{ my: 4, borderRadius: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
Seller's Property Disclosure
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
For Property: {property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* General Information */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
General Information
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="General Known Defects/Issues"
|
||||||
|
secondary={general_defects || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Appliances Included in Sale"
|
||||||
|
secondary={appliances_included || 'None listed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Property Systems */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Property Systems
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Roof Condition"
|
||||||
|
secondary={roof_condition || 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Approximate Roof Age"
|
||||||
|
secondary={roof_age ? `${roof_age} years` : 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Known Roof Leaks"
|
||||||
|
secondary={formatBoolean(known_roof_leaks)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Plumbing System Issues"
|
||||||
|
secondary={plumbing_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Electrical System Issues"
|
||||||
|
secondary={electrical_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="HVAC Condition"
|
||||||
|
secondary={hvac_condition || 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Approximate HVAC Age"
|
||||||
|
secondary={hvac_age ? `${hvac_age} years` : 'Not disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Environmental & Other Issues */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Environmental & Other Issues
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Known Lead-Based Paint"
|
||||||
|
secondary={formatBoolean(known_lead_paint)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Known Asbestos" secondary={formatBoolean(known_asbestos)} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Known Radon" secondary={formatBoolean(known_radon)} />
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Past or Present Water Damage"
|
||||||
|
secondary={past_water_damage || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Structural Issues"
|
||||||
|
secondary={structural_issues || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Neighborhood Nuisances"
|
||||||
|
secondary={neighborhood_nuisances || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Property Line Disputes"
|
||||||
|
secondary={property_line_disputes || 'None disclosed'}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerDisclosureDisplay;
|
||||||
@@ -13,7 +13,7 @@ const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategor
|
|||||||
return (
|
return (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={category.name}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={category.name}>
|
||||||
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
|
<CategoryCard category={category} onSelectCategory={onSelectCategory} />
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
@@ -21,4 +21,4 @@ const CategoryGrid: React.FC<CategoryGridProps> = ({ categories, onSelectCategor
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryGrid;
|
export default CategoryGrid;
|
||||||
|
|||||||
@@ -1,205 +1,160 @@
|
|||||||
|
import { useState, useEffect, ReactElement } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
import { Box, Card, CardContent, CardMedia, Divider, LinearProgress, Stack, Typography } from '@mui/material';
|
import { VideoProgressAPI } from 'types';
|
||||||
import { DataGrid, GridRenderCellParams } from '@mui/x-data-grid';
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { renderProgress } from '@mui/x-data-grid-generator';
|
|
||||||
import { GridColDef } from '@mui/x-data-grid';
|
|
||||||
|
|
||||||
type EducationInfoProps = {
|
interface CategoryProgress {
|
||||||
title: string;
|
categoryName: string;
|
||||||
|
totalProgress: number;
|
||||||
|
videoCount: number;
|
||||||
|
averageProgress: number;
|
||||||
}
|
}
|
||||||
interface Row {
|
|
||||||
id: number;
|
|
||||||
task: string;
|
|
||||||
progress: number; // Value from 0 to 100 for the progress bar
|
|
||||||
}
|
|
||||||
export const EducationInfoCards = () => {
|
|
||||||
return(
|
|
||||||
<Stack direction={{sm:'row'}} justifyContent={{ sm: 'space-between' }} gap={3.75}>
|
|
||||||
<EducationInfo title={'Education'} />
|
|
||||||
|
|
||||||
|
interface EducationInfoCardProps {
|
||||||
|
category: string;
|
||||||
|
progress: number;
|
||||||
|
totalVideos: number;
|
||||||
|
completedVideos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EducationInfoCard = ({
|
||||||
|
category,
|
||||||
|
progress,
|
||||||
|
totalVideos,
|
||||||
|
completedVideos,
|
||||||
|
}: EducationInfoCardCardProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Card sx={{ boxShadow: 4, width: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
{category}
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={progress}
|
||||||
|
sx={{ height: 8, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{completedVideos} of {totalVideos} videos complete
|
||||||
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
</CardContent>
|
||||||
}
|
|
||||||
|
|
||||||
const EducationInfo = ({ title }: EducationInfoProps): ReactElement => {
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
|
||||||
{
|
|
||||||
field: 'id',
|
|
||||||
headerName: 'ID'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'title',
|
|
||||||
headerName: 'Title',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'category',
|
|
||||||
headerName: 'Category',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'progress',
|
|
||||||
headerName: 'Progress',
|
|
||||||
flex: 1,
|
|
||||||
renderCell: (params: GridRenderCellParams<Row, number>) => {
|
|
||||||
const progressValue = params.value; // Access the progress value from the row data
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Box sx={{ width: '100%', mr: 1 }}>
|
|
||||||
<LinearProgress variant="determinate" value={progressValue} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ minWidth: 35 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">{`${progressValue}%`}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
headerName: 'Status',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "How to Research Comparable Properties Like a Pro",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Understanding Price Per Square Foot in Your Neighborhood",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Psychological Pricing: Why $399,900 Works Better Than $400,000",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "When and How to Adjust Your Asking Price",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Handling Lowball Offers: Strategies That Work",
|
|
||||||
category: "Pricing Strategy",
|
|
||||||
progress: 100,
|
|
||||||
status: "COMPLETED",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "The Ultimate Home Staging Checklist for FSBO Sellers",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 90,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
title: "DIY Curb Appeal Upgrades Under $500",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 80,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
title: "Decluttering Secrets for Faster Sales",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 5,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
title: "Professional Photography Tips Using Just Your Smartphone",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 50,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
title: "Deep Cleaning Checklist Before Listing",
|
|
||||||
category: "Property Preparation",
|
|
||||||
progress: 50,
|
|
||||||
status: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
title: "How to stage a home",
|
|
||||||
category: "",
|
|
||||||
progress: 0,
|
|
||||||
status: "NOT_STARTED",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return(
|
|
||||||
<Card
|
|
||||||
sx={(theme) => ({
|
|
||||||
boxShadow: theme.shadows[4],
|
|
||||||
width: 1,
|
|
||||||
height: 'auto',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
sx={{
|
|
||||||
flex: '1 1 auto',
|
|
||||||
padding: 0,
|
|
||||||
':last-child': {
|
|
||||||
paddingBottom: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
|
|
||||||
<Typography variant="subtitle1" component="p" minWidth={100} color="text.primary">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
bgcolor="background.paper"
|
|
||||||
borderRadius={5}
|
|
||||||
width={1}
|
|
||||||
boxShadow={(theme) => theme.shadows[4]}
|
|
||||||
height={1}
|
|
||||||
>
|
|
||||||
<DataGrid
|
|
||||||
getRowHeight={() => 70}
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
onRowClick={(event) => navigate('lesson')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
export const EducationInfoCards = () => {
|
||||||
|
const [videoProgressData, setVideoProgressData] = useState<VideoProgressAPI[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
export default EducationInfo;
|
useEffect(() => {
|
||||||
|
// This is a mock function. Replace with your actual API call.
|
||||||
|
const fetchVideoProgress = async (): Promise<VideoProgressAPI[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get(`/videos/progress/`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchVideoProgress();
|
||||||
|
setVideoProgressData(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch video progress data.');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCategoryProgress = () => {
|
||||||
|
if (!videoProgressData) return {};
|
||||||
|
|
||||||
|
const categories: { [key: string]: CategoryProgress } = {};
|
||||||
|
|
||||||
|
videoProgressData.forEach((item) => {
|
||||||
|
// Access the category name from the nested object
|
||||||
|
const categoryName = item.video.category?.name;
|
||||||
|
if (!categoryName) return; // Skip items without a category name
|
||||||
|
|
||||||
|
if (!categories[categoryName]) {
|
||||||
|
categories[categoryName] = {
|
||||||
|
categoryName: categoryName,
|
||||||
|
totalProgress: 0,
|
||||||
|
videoCount: 0,
|
||||||
|
averageProgress: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
categories[categoryName].totalProgress += item.progress;
|
||||||
|
categories[categoryName].videoCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate average progress for each category
|
||||||
|
for (const key in categories) {
|
||||||
|
const categoryInfo = categories[key];
|
||||||
|
categoryInfo.averageProgress = Math.round(
|
||||||
|
categoryInfo.totalProgress / categoryInfo.videoCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryProgressData = getCategoryProgress();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Typography>Loading progress...</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Alert severity="error">{error}</Alert>;
|
||||||
|
}
|
||||||
|
if (videoProgressData.length === 0) {
|
||||||
|
return <Typography>There are no videos yet</Typography>;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ sm: 'row' }}
|
||||||
|
justifyContent={{ sm: 'space-between' }}
|
||||||
|
gap={3.75}
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{Object.values(categoryProgressData).map((data, index) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
|
||||||
|
<EducationInfoCard
|
||||||
|
category={data.categoryName}
|
||||||
|
progress={data.averageProgress}
|
||||||
|
totalVideos={data.videoCount}
|
||||||
|
completedVideos={
|
||||||
|
videoProgressData.filter(
|
||||||
|
(item) =>
|
||||||
|
item.video.category?.name === data.categoryName && item.progress === 100,
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
// src/components/VideoApp/VideoCategoryCard.tsx
|
// src/components/VideoApp/VideoCategoryCard.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent, CardMedia, Typography, Button, LinearProgress, Box } from '@mui/material';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
import { VideoCategory } from 'types';
|
import { VideoCategory } from 'types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface VideoCategoryCardProps {
|
interface VideoCategoryCardProps {
|
||||||
category: VideoCategory;
|
category: VideoCategory;
|
||||||
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
|
onSelectCategory: (categoryId: string) => void; // Now uses categoryId
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
|
const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||||
console.log(category)
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<CardMedia
|
<CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
|
||||||
component="img"
|
|
||||||
height="140"
|
|
||||||
image={category.imageUrl}
|
|
||||||
alt={category.name}
|
|
||||||
/>
|
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
<Typography gutterBottom variant="h5" component="div">
|
<Typography gutterBottom variant="h5" component="div">
|
||||||
{category.name}
|
{category.name}
|
||||||
@@ -28,9 +28,20 @@ const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelec
|
|||||||
{category.description}
|
{category.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ width: '100%', mt: 2 }}>
|
<Box sx={{ width: '100%', mt: 2 }}>
|
||||||
<Typography variant="body2" color="text.secondary">{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography>
|
<Typography
|
||||||
<LinearProgress variant="determinate" value={category.categoryProgress} sx={{ height: 8, borderRadius: 5, mt: 1 }} />
|
variant="body2"
|
||||||
<Typography variant="caption" display="block" align="right">{`${category.categoryProgress.toFixed(0)}%`}</Typography>
|
color="text.secondary"
|
||||||
|
>{`${category.completedVideos}/${category.totalVideos} videos completed`}</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={category.categoryProgress}
|
||||||
|
sx={{ height: 8, borderRadius: 5, mt: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
display="block"
|
||||||
|
align="right"
|
||||||
|
>{`${category.categoryProgress.toFixed(0)}%`}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Box sx={{ p: 2, pt: 0 }}>
|
<Box sx={{ p: 2, pt: 0 }}>
|
||||||
@@ -42,4 +53,4 @@ const VideoCategoryCard: React.FC<VideoCategoryCardProps> = ({ category, onSelec
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VideoCategoryCard;
|
export default VideoCategoryCard;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
|
import { Box, Typography, Paper, Tooltip, IconButton } from '@mui/material';
|
||||||
import { AccountContext } from 'contexts/AccountContext';
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
import {axiosInstance} from '../../../../../axiosApi';
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
import { VideoItem, VideoProgressAPI } from 'types';
|
import { VideoItem, VideoProgressAPI } from 'types';
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import PauseIcon from '@mui/icons-material/Pause';
|
import PauseIcon from '@mui/icons-material/Pause';
|
||||||
@@ -20,22 +20,34 @@ interface VideoProgress {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
video: VideoItem;
|
video: VideoItem;
|
||||||
|
updateVideoItem: (time: number, completed: boolean, progress: number, videoId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({ video, updateVideoItem }) => {
|
||||||
const {account, accountLoading} = useContext(AccountContext);
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
|
||||||
if (!video || accountLoading) {
|
if (!video || accountLoading) {
|
||||||
return (
|
return (
|
||||||
<Paper elevation={2} sx={{ p: 3, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Paper
|
||||||
<Typography variant="h6" color="text.secondary">No video selected</Typography>
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
No video selected
|
||||||
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
//
|
|
||||||
|
//
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -47,37 +59,41 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Function to save progress to the backend
|
// Function to save progress to the backend
|
||||||
const saveProgress = useCallback(async (time: number, completed: boolean = false) => {
|
const saveProgress = useCallback(
|
||||||
if (!videoRef.current) return;
|
async (time: number, completed: boolean = false) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
const progressData: VideoProgress = {
|
const progressData: VideoProgress = {
|
||||||
|
progress: Math.round(time),
|
||||||
progress: Math.round(time),
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, try to fetch existing progress
|
// First, try to fetch existing progress
|
||||||
const response = await axiosInstance.get(`/videos/progress/?user=${account?.id}&video=${video.id}`);
|
const response = await axiosInstance.get(
|
||||||
if (response.data.length > 0) {
|
`/videos/progress/?user=${account?.id}&video=${video.id}`,
|
||||||
// If progress exists, update it
|
);
|
||||||
const existingProgress = response.data[0];
|
if (response.data.length > 0) {
|
||||||
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
|
// If progress exists, update it
|
||||||
} else {
|
const existingProgress = response.data[0];
|
||||||
// If no progress, create a new one
|
await axiosInstance.patch(`/videos/progress/${existingProgress.id}/`, progressData);
|
||||||
await axiosInstance.post('/videos/progress/', progressData);
|
await updateVideoItem(time, completed, progressData.progress, video.id);
|
||||||
|
} else {
|
||||||
|
// If no progress, create a new one
|
||||||
|
await axiosInstance.post('/videos/progress/', progressData);
|
||||||
|
}
|
||||||
|
if (completed) {
|
||||||
|
setSnackbarMessage('Video progress saved: Completed!');
|
||||||
|
} else {
|
||||||
|
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
|
||||||
|
}
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save video progress:', err);
|
||||||
|
setError('Failed to save video progress.');
|
||||||
}
|
}
|
||||||
if (completed) {
|
},
|
||||||
setSnackbarMessage('Video progress saved: Completed!');
|
[video.id, account?.id],
|
||||||
} else {
|
);
|
||||||
setSnackbarMessage(`Video progress saved: ${Math.floor(time)}s`);
|
|
||||||
}
|
|
||||||
setSnackbarOpen(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save video progress:', err);
|
|
||||||
setError('Failed to save video progress.');
|
|
||||||
}
|
|
||||||
}, [video.id, account?.id]);
|
|
||||||
|
|
||||||
// Fetch initial progress when video changes or component mounts
|
// Fetch initial progress when video changes or component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,14 +101,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const {data,} = await axiosInstance.get<VideoProgressAPI>(`/videos/progress/${video.id}/?user=${account?.id}`);
|
const { data } = await axiosInstance.get<VideoProgressAPI>(
|
||||||
|
`/videos/progress/${video.id}/?user=${account?.id}`,
|
||||||
if (data) {
|
);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
const progress: VideoProgress = {
|
const progress: VideoProgress = {
|
||||||
current_time: data.progress,
|
current_time: data.progress,
|
||||||
progress: data.progress,
|
progress: data.progress,
|
||||||
}
|
};
|
||||||
setCurrentTime(progress?.current_time || 0);
|
setCurrentTime(progress?.current_time || 0);
|
||||||
|
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
@@ -128,7 +145,6 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
};
|
};
|
||||||
}, [video.id, account?.id, saveProgress]);
|
}, [video.id, account?.id, saveProgress]);
|
||||||
|
|
||||||
|
|
||||||
// Video event handlers
|
// Video event handlers
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
@@ -148,7 +164,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.play().catch(e => console.error("Error playing video:", e));
|
videoRef.current.play().catch((e) => console.error('Error playing video:', e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,7 +186,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
// Attempt to play if it was playing before, or if it's the first load
|
// Attempt to play if it was playing before, or if it's the first load
|
||||||
if (videoRef.current && currentTime > 0) {
|
if (videoRef.current && currentTime > 0) {
|
||||||
videoRef.current.currentTime = currentTime;
|
videoRef.current.currentTime = currentTime;
|
||||||
videoRef.current.play().catch(e => console.error("Error resuming video:", e));
|
videoRef.current.play().catch((e) => console.error('Error resuming video:', e));
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,7 +211,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
if (!videoRef.current) return;
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
videoRef.current.requestFullscreen().catch(err => {
|
videoRef.current.requestFullscreen().catch((err) => {
|
||||||
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -227,18 +243,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
setSnackbarOpen(false);
|
setSnackbarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={3} sx={{ p: 2 }}>
|
<Paper elevation={3} sx={{ p: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>{video.name}</Typography>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>{video.description}</Typography>
|
{video.name}
|
||||||
<Box sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}>
|
</Typography>
|
||||||
<video
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
ref={videoRef}
|
{video.description}
|
||||||
src={video.videoUrl}
|
</Typography>
|
||||||
controls={false}
|
<Box
|
||||||
onTimeUpdate={handleTimeUpdate}
|
sx={{ position: 'relative', width: '100%', paddingTop: '56.25%' /* 16:9 Aspect Ratio */ }}
|
||||||
onEnded={handleEnded}
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={video.videoUrl}
|
||||||
|
controls={false}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onEnded={handleEnded}
|
||||||
onLoadedData={handleLoadedData}
|
onLoadedData={handleLoadedData}
|
||||||
onError={handleVideoError}
|
onError={handleVideoError}
|
||||||
style={{
|
style={{
|
||||||
@@ -250,13 +271,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Typography>Your browser does nto support the video tag.</Typography>
|
||||||
>
|
</video>
|
||||||
<Typography>
|
|
||||||
Your browser does nto support the video tag.
|
|
||||||
</Typography>
|
|
||||||
</video>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
@@ -276,15 +293,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ video }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
|
<Tooltip title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}>
|
||||||
<IconButton onClick={toggleFullScreen} color="primary" size="large">
|
<IconButton onClick={toggleFullScreen} color="primary" size="large">
|
||||||
{isFullScreen ? <ExitFullscreenIcon fontSize="inherit" /> : <FullscreenIcon fontSize="inherit" />}
|
{isFullScreen ? (
|
||||||
|
<ExitFullscreenIcon fontSize="inherit" />
|
||||||
|
) : (
|
||||||
|
<FullscreenIcon fontSize="inherit" />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||||
Current Progress: {Math.round(video.progress/video.duration*100)}% - Status: {video.status}
|
Current Progress: {Math.round((video.progress / video.duration) * 100)}% - Status:{' '}
|
||||||
|
{video.status}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VideoPlayer;
|
export default VideoPlayer;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { ReactElement, useState, useEffect } from 'react';
|
||||||
|
import { axiosInstance } from '../../../../../axiosApi';
|
||||||
|
import { PropertiesAPI, VendorAPI, VendorItem } from 'types';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
type CreateOfferDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
createConversation: () => void;
|
||||||
|
setSelectedVendor: React.Dispatch<React.SetStateAction<VendorItem | null>>;
|
||||||
|
vendors: VendorAPI[];
|
||||||
|
selectedVendor: VendorAPI | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateConversationDialogContent = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
createConversation,
|
||||||
|
setSelectedVendor,
|
||||||
|
vendors,
|
||||||
|
selectedVendor,
|
||||||
|
}: CreateOfferDialogProps): ReactElement => {
|
||||||
|
const [options, setOptions] = useState<string[]>(vendors.map((vendor) => vendor.business_name));
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filteredVendorNames: string[] = vendors.map((vendor) => {
|
||||||
|
return vendor.business_name;
|
||||||
|
});
|
||||||
|
setOptions(filteredVendorNames);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = async (event, newInputValue) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
const inputValue = newInputValue.toLowerCase();
|
||||||
|
const filteredVendors = vendors.filter((vendor) =>
|
||||||
|
vendor.business_name.toLowerCase().includes(inputValue),
|
||||||
|
);
|
||||||
|
const filteredVendorNames: string[] = filteredVendors.map((vendor) => {
|
||||||
|
return vendor.business_name;
|
||||||
|
});
|
||||||
|
setOptions(filteredVendorNames);
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
|
||||||
|
<DialogTitle>Start a new Conversation</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Autocomplete
|
||||||
|
options={options} //vendors}
|
||||||
|
//getOptionLabel={//(option) => option.name}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
const filteredVendors = vendors.filter((vendor) => vendor.business_name === newValue);
|
||||||
|
setSelectedVendor(filteredVendors[0]);
|
||||||
|
}}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the vendor to search for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select a Vendor" variant="outlined" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button onClick={createConversation} color="primary" disabled={!selectedVendor}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateConversationDialogContent;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// src/components/PropertyOwnerProfile/AddOpenHouseDialog.tsx
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PropertiesAPI, OpenHouseAPI } from 'types'; // Ensure you have OpenHouseAPI in your types
|
||||||
|
|
||||||
|
import { DatePicker, TimePicker, LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
|
||||||
|
interface AddOpenHouseDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddOpenHouse: (openHouse: Omit<OpenHouseAPI, 'id'> & { listed_date: string }) => void;
|
||||||
|
properties: PropertiesAPI[];
|
||||||
|
errors: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddOpenHouseDialog: React.FC<AddOpenHouseDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAddOpenHouse,
|
||||||
|
properties,
|
||||||
|
errors,
|
||||||
|
}) => {
|
||||||
|
const [propertyId, setPropertyId] = useState<number | ''>('');
|
||||||
|
const [selectedProperty, setSelectedProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
const [startTime, setStartTime] = useState<Date | null>(new Date());
|
||||||
|
const [endTime, setEndTime] = useState<Date | null>(new Date());
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date());
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleAddOpenHouse = () => {
|
||||||
|
if (selectedProperty && startTime && endTime && date) {
|
||||||
|
const newOpenHouse = {
|
||||||
|
property: selectedProperty.id,
|
||||||
|
start_time: startTime.toTimeString().split(' ')[0],
|
||||||
|
end_time: endTime.toTimeString().split(' ')[0],
|
||||||
|
listed_date: date.toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddOpenHouse(newOpenHouse);
|
||||||
|
// onClose();
|
||||||
|
// selectedProperty(null);
|
||||||
|
// setStartTime(new Date());
|
||||||
|
// setEndTime(new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(errors);
|
||||||
|
|
||||||
|
const handleInputChange = async (event: React.SyntheticEvent, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
if (newInputValue) {
|
||||||
|
const filteredPropertiesNames: string[] = properties.map((item) => {
|
||||||
|
return item.address;
|
||||||
|
});
|
||||||
|
setOptions(filteredPropertiesNames);
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose}>
|
||||||
|
<DialogTitle>Add New Open House</DialogTitle> {' '}
|
||||||
|
<DialogContent>
|
||||||
|
{' '}
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
|
{' '}
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
value={selectedProperty?.address || ''}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
const selectedAddr = properties.find((item) => item.address === newValue);
|
||||||
|
setSelectedProperty(selectedAddr || null);
|
||||||
|
}}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
noOptionsText={'Type the address you want to set an open house for'}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select Property" fullWidth required margin="normal" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
label="Date"
|
||||||
|
value={date}
|
||||||
|
onChange={(newValue) => setDate(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
helperText={errors.listed_date}
|
||||||
|
error={!!errors.listed_date}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="Start Time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(newValue) => setStartTime(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="End Time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(newValue) => setEndTime(newValue)}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
{' '}
|
||||||
|
</LocalizationProvider>
|
||||||
|
{' '}
|
||||||
|
</DialogContent>
|
||||||
|
{' '}
|
||||||
|
<DialogActions>
|
||||||
|
{' '}
|
||||||
|
<Button onClick={onClose} color="primary">
|
||||||
|
Cancel {' '}
|
||||||
|
</Button>
|
||||||
|
{' '}
|
||||||
|
<Button
|
||||||
|
onClick={handleAddOpenHouse}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!selectedProperty}
|
||||||
|
>
|
||||||
|
Add {' '}
|
||||||
|
</Button>
|
||||||
|
{' '}
|
||||||
|
</DialogActions>
|
||||||
|
{' '}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddOpenHouseDialog;
|
||||||
@@ -101,7 +101,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
event: React.SyntheticEvent,
|
event: React.SyntheticEvent,
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
const test: boolean = true;
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
let data: AutocompleteDataResponseAPI[] = [];
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
if (value.length > 2) {
|
if (value.length > 2) {
|
||||||
if (test) {
|
if (test) {
|
||||||
@@ -285,7 +285,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
<DialogTitle>Add New Property</DialogTitle>
|
<DialogTitle>Add New Property</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={autocompleteOptions}
|
options={autocompleteOptions}
|
||||||
getOptionLabel={(option) => option.description}
|
getOptionLabel={(option) => option.description}
|
||||||
@@ -319,7 +319,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="City"
|
label="City"
|
||||||
@@ -331,7 +331,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.city}
|
helperText={formErrors.city}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="State"
|
label="State"
|
||||||
@@ -343,7 +343,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.state}
|
helperText={formErrors.state}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Zip Code"
|
label="Zip Code"
|
||||||
@@ -355,7 +355,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.zip_code}
|
helperText={formErrors.zip_code}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Description"
|
label="Description"
|
||||||
@@ -366,7 +366,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Square Footage"
|
label="Square Footage"
|
||||||
@@ -378,7 +378,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.sq_ft}
|
helperText={formErrors.sq_ft}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="# Bedrooms"
|
label="# Bedrooms"
|
||||||
@@ -390,7 +390,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.num_bedrooms}
|
helperText={formErrors.num_bedrooms}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="# Bathrooms"
|
label="# Bathrooms"
|
||||||
@@ -402,7 +402,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
helperText={formErrors.num_bathrooms}
|
helperText={formErrors.num_bathrooms}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Features (comma-separated)"
|
label="Features (comma-separated)"
|
||||||
@@ -416,7 +416,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Market Value"
|
label="Market Value"
|
||||||
@@ -425,7 +425,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Amount"
|
label="Loan Amount"
|
||||||
@@ -434,7 +434,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Term (years)"
|
label="Loan Term (years)"
|
||||||
@@ -444,7 +444,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Start Date"
|
label="Loan Start Date"
|
||||||
@@ -455,7 +455,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Button variant="contained" component="label">
|
<Button variant="contained" component="label">
|
||||||
Upload Pictures
|
Upload Pictures
|
||||||
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
|
<input type="file" hidden multiple accept="image/*" onChange={handlePictureUpload} />
|
||||||
@@ -472,7 +472,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
|||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
{newProperty.latitude && newProperty.longitude && (
|
{newProperty.latitude && newProperty.longitude && (
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<MapComponent
|
<MapComponent
|
||||||
lat={newProperty.latitude}
|
lat={newProperty.latitude}
|
||||||
lng={newProperty.longitude}
|
lng={newProperty.longitude}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, ReactElement } from 'react';
|
import React, { useState, useEffect, ReactElement } from 'react';
|
||||||
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
|
import { Container, Typography, Box, Alert, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
import { AttorneyAPI, UserAPI } from '../types/api';
|
import { AttorneyAPI, UserAPI } from '../../../../../types';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
import { ProfileProps } from 'pages/Profile/Profile';
|
import { ProfileProps } from 'pages/Profile/Profile';
|
||||||
import DashboardLoading from '../Dashboard/DashboardLoading';
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
||||||
@@ -111,6 +112,10 @@ const AttorneyProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
onUpgrade={handleUpgradeSubscription}
|
onUpgrade={handleUpgradeSubscription}
|
||||||
onSave={handleSaveAttorneyProfile}
|
onSave={handleSaveAttorneyProfile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
event: React.SyntheticEvent,
|
event: React.SyntheticEvent,
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
const test: boolean = true;
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
let data: AutocompleteDataResponseAPI[] = [];
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
if (value.length > 2) {
|
if (value.length > 2) {
|
||||||
if (test) {
|
if (test) {
|
||||||
@@ -243,7 +243,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="First Name"
|
label="First Name"
|
||||||
@@ -253,7 +253,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
@@ -263,7 +263,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
@@ -274,7 +274,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Firm Name"
|
label="Firm Name"
|
||||||
@@ -287,7 +287,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.firm_name}
|
helperText={formErrors.firm_name}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
@@ -300,7 +300,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.phone_number}
|
helperText={formErrors.phone_number}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={autocompleteOptions}
|
options={autocompleteOptions}
|
||||||
getOptionLabel={(option) => option.description}
|
getOptionLabel={(option) => option.description}
|
||||||
@@ -345,7 +345,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.address}
|
helperText={formErrors.address}
|
||||||
/>*/}
|
/>*/}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="City"
|
label="City"
|
||||||
@@ -358,7 +358,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.city}
|
helperText={formErrors.city}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="State"
|
label="State"
|
||||||
@@ -371,7 +371,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.state}
|
helperText={formErrors.state}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Zip Code"
|
label="Zip Code"
|
||||||
@@ -399,7 +399,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>*/}
|
</Grid>*/}
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Years of Experience"
|
label="Years of Experience"
|
||||||
@@ -412,7 +412,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
helperText={formErrors.years_experience}
|
helperText={formErrors.years_experience}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Licensed States (comma-separated)"
|
label="Licensed States (comma-separated)"
|
||||||
@@ -427,7 +427,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Website URL"
|
label="Website URL"
|
||||||
@@ -437,7 +437,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Biography"
|
label="Biography"
|
||||||
@@ -449,7 +449,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
component="label"
|
component="label"
|
||||||
@@ -460,7 +460,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
|
<input type="file" hidden accept="image/*" onChange={handleProfilePictureUpload} />
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Typography variant="subtitle1">
|
<Typography variant="subtitle1">
|
||||||
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
Subscription Tier: {attorney.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -477,7 +477,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{editedAttorney.latitude && editedAttorney.longitude && (
|
{editedAttorney.latitude && editedAttorney.longitude && (
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
Firm Location on Map:
|
Firm Location on Map:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
LinearProgress,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { axiosInstance } from 'axiosApi';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
const ChangePasswordCard = () => {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState(0);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const password = e.target.value;
|
||||||
|
setNewPassword(password);
|
||||||
|
const strength = zxcvbn(password).score;
|
||||||
|
setPasswordStrength(strength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordStrength < 2) {
|
||||||
|
setErrors({ newPassword: 'Password is too weak' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axiosInstance.post('/auth/password/change/', {
|
||||||
|
old_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
setMessage({ type: 'success', text: 'Password updated successfully!' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordStrength(0);
|
||||||
|
} catch (error: any) {
|
||||||
|
setMessage({ type: 'error', text: 'Error updating password.' });
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
setErrors(error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrengthColor = () => {
|
||||||
|
switch (passwordStrength) {
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
return 'error';
|
||||||
|
case 2:
|
||||||
|
return 'warning';
|
||||||
|
case 3:
|
||||||
|
case 4:
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Change Password" />
|
||||||
|
<Divider />
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{message && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Alert severity={message.type}>{message.text}</Alert>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
error={!!errors.old_password}
|
||||||
|
helperText={errors.old_password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
error={!!errors.new_password}
|
||||||
|
helperText={errors.new_password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Box sx={{ width: '100%', mt: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(passwordStrength / 4) * 100}
|
||||||
|
color={passwordStrengthColor()}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
{['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][passwordStrength]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
error={!!errors.confirmPassword}
|
||||||
|
helperText={errors.confirmPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button type="submit" variant="contained" color="primary">
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangePasswordCard;
|
||||||
@@ -1,28 +1,97 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, Typography, TextField, Button, Box, Alert } from '@mui/material';
|
import { Card, CardContent, Typography, TextField, Button, Alert, Tooltip } from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface OfferSubmissionCardProps {
|
interface OfferSubmissionCardProps {
|
||||||
onOfferSubmit: (offerAmount: number) => void;
|
onOfferSubmit: (
|
||||||
|
offerAmount: number,
|
||||||
|
closing_days: number,
|
||||||
|
contingencies: string,
|
||||||
|
) => Promise<{ status: number; message?: string }>;
|
||||||
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
|
listingStatus: 'active' | 'pending' | 'contingent' | 'sold' | 'off_market';
|
||||||
|
listingPrice: number;
|
||||||
|
existingOffer?: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
||||||
onOfferSubmit,
|
onOfferSubmit,
|
||||||
listingStatus,
|
listingStatus,
|
||||||
|
listingPrice,
|
||||||
|
existingOffer,
|
||||||
}) => {
|
}) => {
|
||||||
const [offerAmount, setOfferAmount] = useState<string>('');
|
const [offerAmount, setOfferAmount] = useState<string>('');
|
||||||
|
const [closingDuration, setClosingDuration] = useState<string>('');
|
||||||
|
const [contingencies, setContingencies] = useState<string>('None');
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getClosingDate = () => {
|
||||||
|
if (closingDuration) {
|
||||||
|
const days = parseInt(closingDuration, 10);
|
||||||
|
if (!isNaN(days)) {
|
||||||
|
const closingDate = new Date();
|
||||||
|
closingDate.setDate(closingDate.getDate() + days);
|
||||||
|
return closingDate.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const offerPercentage =
|
||||||
|
offerAmount && listingPrice ? (parseFloat(offerAmount) / listingPrice) * 100 : 0;
|
||||||
|
|
||||||
|
if (existingOffer) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Offer Already Submitted
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
You have already submitted an offer for this property.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate(`/documents?selectedDocument=${existingOffer.document_id}`)}
|
||||||
|
>
|
||||||
|
View Offer
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (listingStatus === 'active') {
|
if (listingStatus === 'active') {
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
const amount = parseFloat(offerAmount);
|
const amount = parseFloat(offerAmount);
|
||||||
if (amount > 0) {
|
const closing_days = parseFloat(closingDuration);
|
||||||
onOfferSubmit(amount);
|
if (amount > 0 && closing_days) {
|
||||||
setSubmitted(true);
|
try {
|
||||||
setTimeout(() => setSubmitted(false), 5000);
|
const response = await onOfferSubmit(amount, closing_days, contingencies);
|
||||||
|
if (response.status === 200 || response.status === 201) {
|
||||||
|
setSubmitted(true);
|
||||||
|
setError(null);
|
||||||
|
setTimeout(() => setSubmitted(false), 5000);
|
||||||
|
} else {
|
||||||
|
setError(response.message || 'An unknown error occurred.');
|
||||||
|
setSubmitted(false);
|
||||||
|
setTimeout(() => setError(null), 5000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to submit offer.');
|
||||||
|
setSubmitted(false);
|
||||||
|
setTimeout(() => setError(null), 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isButtonDisabled = !offerAmount || !closingDuration;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -36,8 +105,38 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
|||||||
value={offerAmount}
|
value={offerAmount}
|
||||||
onChange={(e) => setOfferAmount(e.target.value)}
|
onChange={(e) => setOfferAmount(e.target.value)}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
|
helperText={
|
||||||
|
offerPercentage > 0
|
||||||
|
? `This offer is ${offerPercentage.toFixed(2)}% of the listing price.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" color="primary" fullWidth onClick={handleSubmit}>
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Closing Duration (days)"
|
||||||
|
type="number"
|
||||||
|
value={closingDuration}
|
||||||
|
onChange={(e) => setClosingDuration(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
helperText={closingDuration ? `Estimated closing date: ${getClosingDate()}` : ''}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Typical contingencies include financing, inspection, and appraisal.">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Contingencies"
|
||||||
|
type="text"
|
||||||
|
value={contingencies}
|
||||||
|
onChange={(e) => setContingencies(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
>
|
||||||
Submit Offer
|
Submit Offer
|
||||||
</Button>
|
</Button>
|
||||||
{submitted && (
|
{submitted && (
|
||||||
@@ -45,6 +144,11 @@ const OfferSubmissionCard: React.FC<OfferSubmissionCardProps> = ({
|
|||||||
Your offer of ${offerAmount} has been submitted!
|
Your offer of ${offerAmount} has been submitted!
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,58 +1,66 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Divider } from '@mui/material';
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
import { OpenHouseAPI } from 'types';
|
import { OpenHouseAPI } from 'types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface OpenHouseCardProps {
|
interface OpenHouseCardProps {
|
||||||
openHouses: OpenHouseAPI[] | undefined;
|
openHouses: OpenHouseAPI[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
|
const OpenHouseCard: React.FC<OpenHouseCardProps> = ({ openHouses }) => {
|
||||||
if(openHouses){
|
if (openHouses) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Open House Information
|
Open House Information
|
||||||
</Typography>
|
</Typography>
|
||||||
{openHouses.length > 0 ? (
|
{openHouses.length > 0 ? (
|
||||||
<List dense>
|
<List dense>
|
||||||
{openHouses.map((openHouse, index) => (
|
{openHouses.map((openHouse, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${openHouse.date} at ${openHouse.time}`}
|
primary={`${format(new Date(openHouse.listed_date), 'MMM d, yyyy')} at ${format(
|
||||||
secondary={`Agent: ${openHouse.agent} (${openHouse.contact})`}
|
new Date(`1970-01-01T${openHouse.start_time}`),
|
||||||
/>
|
'h a',
|
||||||
</ListItem>
|
)} - ${format(new Date(`1970-01-01T${openHouse.end_time}`), 'h a')}`}
|
||||||
{index < openHouses.length - 1 && <Divider component="li" />}
|
/>
|
||||||
</React.Fragment>
|
</ListItem>
|
||||||
))}
|
{index < openHouses.length - 1 && <Divider component="li" />}
|
||||||
</List>
|
</React.Fragment>
|
||||||
) : (
|
))}
|
||||||
<Typography variant="body2" color="text.secondary">
|
</List>
|
||||||
No upcoming open houses scheduled.
|
) : (
|
||||||
</Typography>
|
<Typography variant="body2" color="text.secondary">
|
||||||
)}
|
No upcoming open houses scheduled.
|
||||||
</CardContent>
|
</Typography>
|
||||||
</Card>
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
}else{
|
return (
|
||||||
return (
|
<Card>
|
||||||
<Card>
|
<CardContent>
|
||||||
<CardContent>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Typography variant="h6" gutterBottom>
|
Open House Information
|
||||||
Open House Information
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" color="text.secondary">
|
||||||
<Typography variant="body2" color="text.secondary">
|
No upcoming open houses scheduled.
|
||||||
No upcoming open houses scheduled.
|
</Typography>
|
||||||
</Typography>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OpenHouseCard;
|
export default OpenHouseCard;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// src/components/PropertyOwnerProfile/OpenHouseCard.tsx
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||||
|
import { OpenHouseAPI } from 'types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface OpenHouseCardProps {
|
||||||
|
openHouse: OpenHouseAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OpenHouseDialogContent: React.FC<OpenHouseCardProps> = ({ openHouse }) => {
|
||||||
|
const startTime = new Date(`${openHouse.start_time}`);
|
||||||
|
const endTime = new Date(`${openHouse.end_time}`);
|
||||||
|
|
||||||
|
console.log(endTime);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" component="div">
|
||||||
|
Open House at Property: {openHouse.property.address_line_1}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{openHouse.start_time}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{openHouse.end_time}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenHouseDialogContent;
|
||||||
@@ -20,9 +20,15 @@ interface ProfileCardProps {
|
|||||||
user: UserAPI;
|
user: UserAPI;
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
onSave: (updatedUser: UserAPI) => void;
|
onSave: (updatedUser: UserAPI) => void;
|
||||||
|
setMessage: (
|
||||||
|
value: React.SetStateAction<{
|
||||||
|
type: 'success' | 'error';
|
||||||
|
text: string;
|
||||||
|
} | null>,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) => {
|
const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave, setMessage }) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editedUser, setEditedUser] = useState<UserAPI>(user);
|
const [editedUser, setEditedUser] = useState<UserAPI>(user);
|
||||||
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});
|
||||||
@@ -39,18 +45,23 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
|||||||
console.log(editedUser);
|
console.log(editedUser);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value, type, checked } = e.target;
|
if (isEditing) {
|
||||||
setEditedUser((prev) => ({
|
const { name, value, type, checked } = e.target;
|
||||||
...prev,
|
setEditedUser((prev) => ({
|
||||||
[name]: type === 'checkbox' ? checked : value,
|
...prev,
|
||||||
}));
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
// Clear error for the field being edited
|
}));
|
||||||
if (formErrors[name]) {
|
// Clear error for the field being edited
|
||||||
setFormErrors((prev) => {
|
if (formErrors[name]) {
|
||||||
const newErrors = { ...prev };
|
setFormErrors((prev) => {
|
||||||
delete newErrors[name];
|
const newErrors = { ...prev };
|
||||||
return newErrors;
|
delete newErrors[name];
|
||||||
});
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Enable editing in the top right' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,31 +112,29 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="First Name"
|
label="First Name"
|
||||||
name="first_name"
|
name="first_name"
|
||||||
value={editedUser.first_name}
|
value={editedUser.first_name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={!isEditing}
|
|
||||||
error={!!formErrors.first_name}
|
error={!!formErrors.first_name}
|
||||||
helperText={formErrors.first_name}
|
helperText={formErrors.first_name}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
name="last_name"
|
name="last_name"
|
||||||
value={editedUser.last_name}
|
value={editedUser.last_name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={!isEditing}
|
|
||||||
error={!!formErrors.last_name}
|
error={!!formErrors.last_name}
|
||||||
helperText={formErrors.last_name}
|
helperText={formErrors.last_name}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
@@ -133,12 +142,11 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
|||||||
type="email"
|
type="email"
|
||||||
value={editedUser.email}
|
value={editedUser.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={!isEditing}
|
|
||||||
error={!!formErrors.email}
|
error={!!formErrors.email}
|
||||||
helperText={formErrors.email}
|
helperText={formErrors.email}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Typography variant="subtitle1">
|
<Typography variant="subtitle1">
|
||||||
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
|
Subscription Tier: {user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -148,7 +156,7 @@ const ProfileCard: React.FC<ProfileCardProps> = ({ user, onUpgrade, onSave }) =>
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Typography variant="subtitle1">Notification Settings:</Typography>
|
<Typography variant="subtitle1">Notification Settings:</Typography>
|
||||||
{/* Example Checkboxes - You'd manage these with state too */}
|
{/* Example Checkboxes - You'd manage these with state too */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|||||||
@@ -1,112 +1,104 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Typography,
|
Typography,
|
||||||
Grid,
|
Grid,
|
||||||
Box,
|
Box,
|
||||||
ImageList,
|
ImageList,
|
||||||
ImageListItem,
|
ImageListItem,
|
||||||
ImageListItemBar,
|
ImageListItemBar,
|
||||||
Button,
|
Button,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import MapComponent from '../../../../base/MapComponent';
|
import MapComponent from '../../../../base/MapComponent';
|
||||||
import { PropertiesAPI } from 'types';
|
import { PropertiesAPI } from 'types';
|
||||||
|
|
||||||
interface PropertyCardProps {
|
interface PropertyCardProps {
|
||||||
property: PropertiesAPI;
|
property: PropertiesAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
|
const PropertyCard: React.FC<PropertyCardProps> = ({ property }) => {
|
||||||
// Dummy latitude and longitude for demonstration
|
// Dummy latitude and longitude for demonstration
|
||||||
// In a real app, you'd geocode the address to get these.
|
// In a real app, you'd geocode the address to get these.
|
||||||
const demoLat = 34.0522;
|
const demoLat = 34.0522;
|
||||||
const demoLng = -118.2437; // Example: Los Angeles coordinates
|
const demoLng = -118.2437; // Example: Los Angeles coordinates
|
||||||
console.log(property)
|
console.log(property);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ mt: 3, p: 2 }}>
|
<Card sx={{ mt: 3, p: 2 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
{property.address}, {property.city}, {property.state} {property.zip_code}
|
{property.address}, {property.city}, {property.state} {property.zip_code}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
{property.pictures && property.pictures.length > 0 && (
|
{property.pictures && property.pictures.length > 0 && (
|
||||||
<ImageList cols={property.pictures.length > 1 ? 2 : 1} rowHeight={164} sx={{ maxWidth: 500 }}>
|
<ImageList
|
||||||
{property.pictures.map((item, index) => (
|
cols={property.pictures.length > 1 ? 2 : 1}
|
||||||
<ImageListItem key={index}>
|
rowHeight={164}
|
||||||
<img
|
sx={{ maxWidth: 500 }}
|
||||||
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
|
>
|
||||||
|
{property.pictures.map((item, index) => (
|
||||||
|
<ImageListItem key={index}>
|
||||||
|
<img
|
||||||
|
srcSet={`${item}?w=164&h=164&fit=crop&auto=format 1x,
|
||||||
${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
|
||||||
src={`${item}?w=164&h=164&fit=crop&auto=format`}
|
src={`${item}?w=164&h=164&fit=crop&auto=format`}
|
||||||
alt={`Property image ${index + 1}`}
|
alt={`Property image ${index + 1}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<ImageListItemBar
|
<ImageListItemBar title={`Image ${index + 1}`} />
|
||||||
title={`Image ${index + 1}`}
|
</ImageListItem>
|
||||||
/>
|
))}
|
||||||
</ImageListItem>
|
</ImageList>
|
||||||
))}
|
)}
|
||||||
</ImageList>
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||||
)}
|
<strong>Description:</strong>
|
||||||
<Typography variant="body1" sx={{ mt: 2 }}>
|
</Typography>
|
||||||
<strong>Description:</strong>
|
{property.description ? (
|
||||||
</Typography>
|
<Typography variant="body2" color="textSecondary">
|
||||||
{ property.description ? (
|
{property.description}
|
||||||
<Typography variant="body2" color="textSecondary">
|
</Typography>
|
||||||
{property.description}
|
) : (
|
||||||
</Typography>
|
<Button variant="contained">Generate Description</Button>
|
||||||
|
)}
|
||||||
) : (
|
</Grid>
|
||||||
<Button variant='contained'>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
Generate Description
|
<Typography variant="body1">
|
||||||
</Button>
|
<strong>Stats:</strong>
|
||||||
|
</Typography>
|
||||||
)}
|
<Typography variant="body2">Sq Ft: {property.sq_ft || 'N/A'}</Typography>
|
||||||
|
<Typography variant="body2">Bedrooms: {property.num_bedrooms || 'N/A'}</Typography>
|
||||||
</Grid>
|
<Typography variant="body2">Bathrooms: {property.num_bathrooms || 'N/A'}</Typography>
|
||||||
<Grid item xs={12} md={6}>
|
<Typography variant="body2">
|
||||||
<Typography variant="body1">
|
Features:{' '}
|
||||||
<strong>Stats:</strong>
|
{property.features && property.features.length > 0
|
||||||
</Typography>
|
? property.features.join(', ')
|
||||||
<Typography variant="body2">
|
: 'None'}
|
||||||
Sq Ft: {property.sq_ft || 'N/A'}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2">Market Value: ${property.market_value || 'N/A'}</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">Loan Amount: ${property.loan_amount || 'N/A'}</Typography>
|
||||||
Bedrooms: {property.num_bedrooms || 'N/A'}
|
<Typography variant="body2">
|
||||||
</Typography>
|
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
||||||
<Typography variant="body2">
|
</Typography>
|
||||||
Bathrooms: {property.num_bathrooms || 'N/A'}
|
<Typography variant="body2">
|
||||||
</Typography>
|
Loan Start Date: {property.loan_start_date || 'N/A'}
|
||||||
<Typography variant="body2">
|
</Typography>
|
||||||
Features: {property.features && property.features.length > 0
|
{property.latitude && property.longitude ? (
|
||||||
? property.features.join(', ')
|
<MapComponent
|
||||||
: 'None'}
|
lat={property.latitude}
|
||||||
</Typography>
|
lng={property.longitude}
|
||||||
<Typography variant="body2">
|
address={property.address}
|
||||||
Market Value: ${property.market_value || 'N/A'}
|
/>
|
||||||
</Typography>
|
) : (
|
||||||
<Typography variant="body2">
|
<p>Error loading the map</p>
|
||||||
Loan Amount: ${property.loan_amount || 'N/A'}
|
)}
|
||||||
</Typography>
|
</Grid>
|
||||||
<Typography variant="body2">
|
</Grid>
|
||||||
Loan Term: {property.loan_term ? `${property.loan_term} years` : 'N/A'}
|
</CardContent>
|
||||||
</Typography>
|
</Card>
|
||||||
<Typography variant="body2">
|
);
|
||||||
Loan Start Date: {property.loan_start_date || 'N/A'}
|
|
||||||
</Typography>
|
|
||||||
{property.latitude && property.longitude ? (
|
|
||||||
<MapComponent lat={property.latitude} lng={property.longitude} address={property.address} />
|
|
||||||
) : (
|
|
||||||
<p>Error loading the map</p>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PropertyCard;
|
export default PropertyCard;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
|
import { Alert, Box, Button, Container, Divider, Grid, Paper, Typography } from '@mui/material';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
|
|
||||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI } from 'types';
|
import { PropertiesAPI, PropertyOwnerAPI, PropertyRequestAPI, UserAPI, OpenHouseAPI } from 'types';
|
||||||
import ProfileCard from './ProfileCard';
|
import ProfileCard from './ProfileCard';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import PropertyCard from './PropertyCard.';
|
import PropertyCard from './PropertyCard.';
|
||||||
@@ -13,12 +14,18 @@ import DashboardErrorPage from '../Dashboard/DashboardErrorPage';
|
|||||||
import DashboardLoading from '../Dashboard/DashboardLoading';
|
import DashboardLoading from '../Dashboard/DashboardLoading';
|
||||||
import { ProfileProps } from 'pages/Profile/Profile';
|
import { ProfileProps } from 'pages/Profile/Profile';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import AddOpenHouseDialog from './AddOpenHouseDialog';
|
||||||
|
import OpenHouseDialogContent from './OpenHouseDialogContext';
|
||||||
|
|
||||||
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||||
const [loadingData, setLoadingData] = useState<boolean>(true);
|
const [loadingData, setLoadingData] = useState<boolean>(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
|
const [user, setUser] = useState<PropertyOwnerAPI | null>(null);
|
||||||
|
|
||||||
|
const [openHouses, setOpenHouses] = useState<OpenHouseAPI[]>([]);
|
||||||
|
const [openAddOpenHouseDialog, setOpenAddOpenHouseDialog] = useState(false);
|
||||||
|
const [openHouseErrors, setOpenHouseErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPropertyOwner = async () => {
|
const fetchPropertyOwner = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +59,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
console.log('setting the user to: ', data[0].owner);
|
console.log('setting the user to: ', data[0].owner);
|
||||||
setUser(data[0].owner);
|
setUser(data[0].owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: openHousesData }: AxiosResponse<OpenHouseAPI[]> = await axiosInstance.get(
|
||||||
|
'/properties/open-houses/',
|
||||||
|
);
|
||||||
|
setOpenHouses(openHousesData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,6 +116,35 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
setOpenAddPropertyDialog(false);
|
setOpenAddPropertyDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenAddOpenHouseDialog = () => {
|
||||||
|
setOpenAddOpenHouseDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddOpenHouseDialog = () => {
|
||||||
|
setOpenAddOpenHouseDialog(false);
|
||||||
|
setOpenHouseErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOpenHouse = async (
|
||||||
|
newOpenHouseData: Omit<OpenHouseAPI, 'id'> & { listed_date: string },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<OpenHouseAPI> = await axiosInstance.post(
|
||||||
|
'/properties/open-houses/',
|
||||||
|
newOpenHouseData,
|
||||||
|
);
|
||||||
|
setOpenHouses((prev) => [...prev, data]);
|
||||||
|
setMessage({ type: 'success', text: 'Open house added successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
setOpenAddOpenHouseDialog(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setOpenHouseErrors(error.response.data);
|
||||||
|
setMessage({ type: 'error', text: 'Error adding open house.' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddProperty = (
|
const handleAddProperty = (
|
||||||
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||||
) => {
|
) => {
|
||||||
@@ -134,29 +175,39 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||||
// In a real app, this would be an API call to update the property
|
try {
|
||||||
console.log('Saving property: IMPLEMENT ME', updatedProperty);
|
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||||
|
`/properties/${updatedProperty.id}/`,
|
||||||
|
{
|
||||||
|
...updatedProperty,
|
||||||
|
owner: account.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updatedProperties = properties.map((item) => {
|
||||||
|
if (item.id === data.id) {
|
||||||
|
return { ...item, ...data };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
setProperties(updatedProperties);
|
||||||
|
setMessage({ type: 'success', text: 'Property has been updated' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Error while saving the property. Please try again' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProperty = async (propertyId: number) => {
|
const handleDeleteProperty = async (propertyId: number) => {
|
||||||
console.log('handle delete. IMPLEMENT ME');
|
|
||||||
try {
|
try {
|
||||||
const { data }: AxiosResponse<UserAPI> = await axiosInstance.delete(
|
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||||
`/properties/${propertyId}/`,
|
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||||
);
|
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||||
console.log(data);
|
setTimeout(() => setMessage(null), 3000);
|
||||||
// remove the proprty from the list
|
} catch (error) {
|
||||||
setProperties((prevProperty) => prevProperty.filter((item) => item.id !== propertyId));
|
setMessage({ type: 'error', text: 'Error while removing the property. Please try again' });
|
||||||
// const indexToRemove = properties.findIndex(property => property.id === propertyId);
|
setTimeout(() => setMessage(null), 3000);
|
||||||
// console.log(indexToRemove)
|
|
||||||
// if (indexToRemove !== -1) {
|
|
||||||
// const updatedProperties = properties.splice(indexToRemove, 1)
|
|
||||||
// console.log(updatedProperties)
|
|
||||||
// setProperties(updatedProperties);
|
|
||||||
// }
|
|
||||||
} catch {
|
|
||||||
console.log('error removing');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,8 +233,13 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
user={user.user}
|
user={user.user}
|
||||||
onUpgrade={handleUpgradeSubscription}
|
onUpgrade={handleUpgradeSubscription}
|
||||||
onSave={handleSaveProfile}
|
onSave={handleSaveProfile}
|
||||||
|
setMessage={setMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ my: 4 }} />
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
@@ -194,6 +250,11 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
Add Property
|
Add Property
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{properties.length === 0 ? (
|
{properties.length === 0 ? (
|
||||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
<Typography variant="body1" color="textSecondary">
|
<Typography variant="body1" color="textSecondary">
|
||||||
@@ -203,7 +264,7 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
) : (
|
) : (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{properties.map((property) => (
|
{properties.map((property) => (
|
||||||
<Grid item xs={12} key={property.id}>
|
<Grid size={{ xs: 12 }} key={property.id}>
|
||||||
{/* <PropertyCard property={property} /> */}
|
{/* <PropertyCard property={property} /> */}
|
||||||
<PropertyDetailCard
|
<PropertyDetailCard
|
||||||
property={property}
|
property={property}
|
||||||
@@ -222,6 +283,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
onClose={handleCloseAddPropertyDialog}
|
onClose={handleCloseAddPropertyDialog}
|
||||||
onAddProperty={handleAddProperty}
|
onAddProperty={handleAddProperty}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{properties.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ color: 'background.paper' }}>
|
||||||
|
My Open Houses
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={handleOpenAddOpenHouseDialog}>
|
||||||
|
Add Open House
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{openHouses.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="textSecondary">
|
||||||
|
You have no open houses scheduled.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{openHouses.map((openHouse) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 2 }} key={openHouse.id}>
|
||||||
|
{/* You will create a component to display the open house details */}
|
||||||
|
<OpenHouseDialogContent openHouse={openHouse} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Please add a property before you can schedule an open house.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddOpenHouseDialog
|
||||||
|
open={openAddOpenHouseDialog}
|
||||||
|
onClose={handleCloseAddOpenHouseDialog}
|
||||||
|
onAddOpenHouse={handleAddOpenHouse}
|
||||||
|
properties={properties} // Pass the properties to the dialog
|
||||||
|
errors={openHouseErrors}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
import { UserAPI, VendorAPI } from 'types';
|
import { UserAPI, VendorAPI } from 'types';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -165,6 +166,10 @@ const VendorProfile = ({ account }: ProfileProps): ReactElement => {
|
|||||||
onSave={handleSaveVendorProfile}
|
onSave={handleSaveVendorProfile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<ChangePasswordCard />
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ my: 4 }} />
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
<ServicesCard
|
<ServicesCard
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
event: React.SyntheticEvent,
|
event: React.SyntheticEvent,
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
const test: boolean = true;
|
const test: boolean = !import.meta.env.USE_LIVE_DATA;
|
||||||
let data: AutocompleteDataResponseAPI[] = [];
|
let data: AutocompleteDataResponseAPI[] = [];
|
||||||
if (value.length > 2) {
|
if (value.length > 2) {
|
||||||
if (test) {
|
if (test) {
|
||||||
@@ -196,7 +196,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="First Name"
|
label="First Name"
|
||||||
@@ -206,7 +206,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
@@ -216,7 +216,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
@@ -227,7 +227,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Business Name"
|
label="Business Name"
|
||||||
@@ -240,7 +240,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
helperText={formErrors.business_name}
|
helperText={formErrors.business_name}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<FormControl fullWidth disabled={!isEditing}>
|
<FormControl fullWidth disabled={!isEditing}>
|
||||||
<InputLabel id="business-type-label">Business Type</InputLabel>
|
<InputLabel id="business-type-label">Business Type</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -260,7 +260,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
@@ -273,7 +273,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
helperText={formErrors.phone_number}
|
helperText={formErrors.phone_number}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={autocompleteOptions}
|
options={autocompleteOptions}
|
||||||
getOptionLabel={(option) => option.description}
|
getOptionLabel={(option) => option.description}
|
||||||
@@ -307,7 +307,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="City"
|
label="City"
|
||||||
@@ -320,7 +320,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
helperText={formErrors.city}
|
helperText={formErrors.city}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="State"
|
label="State"
|
||||||
@@ -333,7 +333,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
helperText={formErrors.state}
|
helperText={formErrors.state}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Zip Code"
|
label="Zip Code"
|
||||||
@@ -346,7 +346,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
helperText={formErrors.zip_code}
|
helperText={formErrors.zip_code}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Business Description"
|
label="Business Description"
|
||||||
@@ -358,7 +358,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Website URL"
|
label="Website URL"
|
||||||
@@ -368,7 +368,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Certifications (comma-separated)"
|
label="Certifications (comma-separated)"
|
||||||
@@ -386,7 +386,7 @@ const VendorProfileCard: React.FC<VendorProfileCardProps> = ({ vendor, onUpgrade
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Typography variant="subtitle1">
|
<Typography variant="subtitle1">
|
||||||
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
Subscription Tier: {vendor.user.tier === 'premium' ? 'Premium' : 'Basic'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
|
||||||
|
|
||||||
|
interface LogInNotificationCardProps {}
|
||||||
|
|
||||||
|
const LogInNotificationCard: React.FC<LogInNotificationCardProps> = ({}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goToLogin = async () => {
|
||||||
|
navigate('/authentication/login');
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Want to know more?
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Sign in or create an account to view the seller disclosure document and message the owner
|
||||||
|
with any questions.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" fullWidth onClick={goToLogin}>
|
||||||
|
Log In
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogInNotificationCard;
|
||||||
@@ -159,10 +159,11 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
setIsGernerating(true);
|
setIsGernerating(true);
|
||||||
|
|
||||||
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
||||||
console.log(response);
|
setEditedProperty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: response.data.description,
|
||||||
|
}));
|
||||||
setIsGernerating(false);
|
setIsGernerating(false);
|
||||||
|
|
||||||
// TODO: toggle the update
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,10 +196,10 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Property Address & Basic Info */}
|
{/* Property Address & Basic Info */}
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12 }}>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={8}>
|
<Grid size={{ xs: 8 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Address"
|
label="Address"
|
||||||
@@ -210,7 +211,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.address}
|
helperText={formErrors.address}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="City"
|
label="City"
|
||||||
@@ -222,7 +223,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.city}
|
helperText={formErrors.city}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="State"
|
label="State"
|
||||||
@@ -234,7 +235,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.state}
|
helperText={formErrors.state}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Zip Code"
|
label="Zip Code"
|
||||||
@@ -260,7 +261,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Pictures */}
|
{/* Pictures */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
Pictures:
|
Pictures:
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -306,8 +307,8 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Description & Stats */}
|
{/* Description */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
Description:
|
Description:
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -333,13 +334,16 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
// {property.description || 'No description provided.'}
|
// {property.description || 'No description provided.'}
|
||||||
// </Typography>
|
// </Typography>
|
||||||
)}
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ mt: 2 }} gutterBottom>
|
{/* Stats */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
Stats:
|
Stats:
|
||||||
</Typography>
|
</Typography>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={6}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Sq Ft"
|
label="Sq Ft"
|
||||||
@@ -351,7 +355,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.sq_ft}
|
helperText={formErrors.sq_ft}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Bedrooms"
|
label="Bedrooms"
|
||||||
@@ -363,7 +367,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.num_bedrooms}
|
helperText={formErrors.num_bedrooms}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Bathrooms"
|
label="Bathrooms"
|
||||||
@@ -375,7 +379,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
helperText={formErrors.num_bathrooms}
|
helperText={formErrors.num_bathrooms}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Features (comma-separated)"
|
label="Features (comma-separated)"
|
||||||
@@ -384,7 +388,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
onChange={handleFeaturesChange}
|
onChange={handleFeaturesChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Market Value"
|
label="Market Value"
|
||||||
@@ -393,7 +397,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Amount"
|
label="Loan Amount"
|
||||||
@@ -402,7 +406,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Term (years)"
|
label="Loan Term (years)"
|
||||||
@@ -412,7 +416,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
onChange={handleNumericChange}
|
onChange={handleNumericChange}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Loan Start Date"
|
label="Loan Start Date"
|
||||||
@@ -454,7 +458,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<Grid item xs={12}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
Location on Map:
|
Location on Map:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -7,14 +7,23 @@ import { PropertiesAPI } from 'types';
|
|||||||
interface PropertyListItemProps {
|
interface PropertyListItemProps {
|
||||||
property: PropertiesAPI;
|
property: PropertiesAPI;
|
||||||
onViewDetails: (propertyId: number) => void; // For navigation in search page
|
onViewDetails: (propertyId: number) => void; // For navigation in search page
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PropertyListItem: React.FC<PropertyListItemProps> = ({ property, onViewDetails }) => {
|
const PropertyListItem: React.FC<PropertyListItemProps> = ({
|
||||||
|
property,
|
||||||
|
onViewDetails,
|
||||||
|
isPublic = false,
|
||||||
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleViewDetailsClick = () => {
|
const handleViewDetailsClick = () => {
|
||||||
// Navigate to the full detail page for this property
|
// Navigate to the full detail page for this property
|
||||||
navigate(`/property/${property.id}/?search=1`);
|
if (!isPublic) {
|
||||||
|
navigate(`/property/${property.id}/?search=1`);
|
||||||
|
} else {
|
||||||
|
navigate(`/public/${property.id}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const value_price = property.listed_price ? property.listed_price : property.market_value;
|
const value_price = property.listed_price ? property.listed_price : property.market_value;
|
||||||
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';
|
const value_text = property.listed_price ? 'Listed Price' : 'Market Value';
|
||||||
|
|||||||
@@ -1,107 +1,209 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { TextField, Button, Box, Grid, Paper, Typography } from '@mui/material';
|
import {
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
|
||||||
interface SearchFilters {
|
interface SearchFilters {
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
zipCode: string;
|
zipCode: string;
|
||||||
minSqFt: number | '';
|
minSqFt: number | '';
|
||||||
maxSqFt: number | '';
|
maxSqFt: number | '';
|
||||||
minBedrooms: number | '';
|
minBedrooms: number | '';
|
||||||
maxBedrooms: number | '';
|
maxBedrooms: number | '';
|
||||||
minBathrooms: number | '';
|
minBathrooms: number | '';
|
||||||
maxBathrooms: number | '';
|
maxBathrooms: number | '';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropertySearchFiltersProps {
|
interface PropertySearchFiltersProps {
|
||||||
onSearch: (filters: SearchFilters) => void;
|
onSearch: (filters: SearchFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialFilters: SearchFilters = {
|
const initialFilters: SearchFilters = {
|
||||||
address: '',
|
address: '',
|
||||||
city: '',
|
city: '',
|
||||||
state: '',
|
state: '',
|
||||||
zipCode: '',
|
zipCode: '',
|
||||||
minSqFt: '',
|
minSqFt: '',
|
||||||
maxSqFt: '',
|
maxSqFt: '',
|
||||||
minBedrooms: '',
|
minBedrooms: '',
|
||||||
maxBedrooms: '',
|
maxBedrooms: '',
|
||||||
minBathrooms: '',
|
minBathrooms: '',
|
||||||
maxBathrooms: '',
|
maxBathrooms: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
|
const PropertySearchFilters: React.FC<PropertySearchFiltersProps> = ({ onSearch, onClear }) => {
|
||||||
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
|
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFilters(prev => ({ ...prev, [name]: value }));
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
const numValue = value === '' ? '' : parseFloat(value);
|
const numValue = value === '' ? '' : parseFloat(value);
|
||||||
setFilters(prev => ({ ...prev, [name]: numValue }));
|
setFilters((prev) => ({ ...prev, [name]: numValue }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
onSearch(filters);
|
onSearch(filters);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearClick = () => {
|
const handleClearClick = () => {
|
||||||
setFilters(initialFilters);
|
setFilters(initialFilters);
|
||||||
onClear();
|
onClear();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleToggleExpand = () => {
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
setExpanded(!expanded);
|
||||||
<Typography variant="h6" gutterBottom>Search Properties</Typography>
|
};
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
return (
|
||||||
<TextField fullWidth label="Address Keyword" name="address" value={filters.address} onChange={handleChange} />
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
</Grid>
|
<Box
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
sx={{
|
||||||
<TextField fullWidth label="City" name="city" value={filters.city} onChange={handleChange} />
|
display: 'flex',
|
||||||
</Grid>
|
justifyContent: 'space-between',
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
alignItems: 'center',
|
||||||
<TextField fullWidth label="State" name="state" value={filters.state} onChange={handleChange} />
|
cursor: 'pointer',
|
||||||
</Grid>
|
}}
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
onClick={handleToggleExpand}
|
||||||
<TextField fullWidth label="Zip Code" name="zipCode" value={filters.zipCode} onChange={handleChange} />
|
>
|
||||||
</Grid>
|
<Typography variant="h6" gutterBottom>
|
||||||
<Grid item xs={6} sm={3}>
|
Property Filters
|
||||||
<TextField fullWidth label="Min Sq Ft" name="minSqFt" type="number" value={filters.minSqFt} onChange={handleNumericChange} />
|
</Typography>
|
||||||
</Grid>
|
<IconButton
|
||||||
<Grid item xs={6} sm={3}>
|
onClick={handleToggleExpand}
|
||||||
<TextField fullWidth label="Max Sq Ft" name="maxSqFt" type="number" value={filters.maxSqFt} onChange={handleNumericChange} />
|
aria-expanded={expanded}
|
||||||
</Grid>
|
aria-label="toggle filters"
|
||||||
<Grid item xs={6} sm={3}>
|
>
|
||||||
<TextField fullWidth label="Min Bedrooms" name="minBedrooms" type="number" value={filters.minBedrooms} onChange={handleNumericChange} />
|
<ExpandMoreIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} />
|
||||||
</Grid>
|
</IconButton>
|
||||||
<Grid item xs={6} sm={3}>
|
</Box>
|
||||||
<TextField fullWidth label="Max Bedrooms" name="maxBedrooms" type="number" value={filters.maxBedrooms} onChange={handleNumericChange} />
|
<Collapse in={expanded}>
|
||||||
</Grid>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<TextField fullWidth label="Min Bathrooms" name="minBathrooms" type="number" value={filters.minBathrooms} onChange={handleNumericChange} />
|
<TextField
|
||||||
</Grid>
|
fullWidth
|
||||||
<Grid item xs={6} sm={3}>
|
label="Address Keyword"
|
||||||
<TextField fullWidth label="Max Bathrooms" name="maxBathrooms" type="number" value={filters.maxBathrooms} onChange={handleNumericChange} />
|
name="address"
|
||||||
</Grid>
|
value={filters.address}
|
||||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
onChange={handleChange}
|
||||||
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
|
/>
|
||||||
Clear Filters
|
</Grid>
|
||||||
</Button>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
|
<TextField
|
||||||
Search
|
fullWidth
|
||||||
</Button>
|
label="City"
|
||||||
</Grid>
|
name="city"
|
||||||
</Grid>
|
value={filters.city}
|
||||||
</Paper>
|
onChange={handleChange}
|
||||||
);
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="State"
|
||||||
|
name="state"
|
||||||
|
value={filters.state}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Zip Code"
|
||||||
|
name="zipCode"
|
||||||
|
value={filters.zipCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Sq Ft"
|
||||||
|
name="minSqFt"
|
||||||
|
type="number"
|
||||||
|
value={filters.minSqFt}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Sq Ft"
|
||||||
|
name="maxSqFt"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxSqFt}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Bedrooms"
|
||||||
|
name="minBedrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.minBedrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Bedrooms"
|
||||||
|
name="maxBedrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxBedrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Min Bathrooms"
|
||||||
|
name="minBathrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.minBathrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Max Bathrooms"
|
||||||
|
name="maxBathrooms"
|
||||||
|
type="number"
|
||||||
|
value={filters.maxBathrooms}
|
||||||
|
onChange={handleNumericChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleClearClick}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleSearchClick}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PropertySearchFilters;
|
export default PropertySearchFilters;
|
||||||
|
|||||||
@@ -13,13 +13,22 @@ import {
|
|||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import { PropertiesAPI } from 'types';
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
|
|
||||||
|
const getIcon = (
|
||||||
|
savedProperty: SavedPropertiesAPI | null,
|
||||||
|
): typeof FavoriteBorderIcon | typeof FavoriteIcon => {
|
||||||
|
return savedProperty ? FavoriteIcon : FavoriteBorderIcon;
|
||||||
|
};
|
||||||
|
|
||||||
interface PropertyStatusCardProps {
|
interface PropertyStatusCardProps {
|
||||||
property: PropertiesAPI;
|
property: PropertiesAPI;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
onStatusChange?: () => void;
|
onStatusChange?: (string) => void;
|
||||||
onSavedPropertySave?: () => void;
|
onSavedPropertySave?: () => void;
|
||||||
|
savedProperty: SavedPropertiesAPI | null;
|
||||||
|
sellerDisclosureExists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
||||||
@@ -27,7 +36,17 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
|||||||
isOwner,
|
isOwner,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSavedPropertySave,
|
onSavedPropertySave,
|
||||||
|
savedProperty,
|
||||||
|
sellerDisclosureExists,
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleStatusChange = (e) => {
|
||||||
|
const newStatus = e.target.value;
|
||||||
|
if (newStatus === 'active' && !sellerDisclosureExists) {
|
||||||
|
alert('A seller disclosure document is required before putting the property on the market.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStatusChange(newStatus);
|
||||||
|
};
|
||||||
const getStatusColor = (status: PropertiesAPI['property_status']) => {
|
const getStatusColor = (status: PropertiesAPI['property_status']) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
@@ -44,7 +63,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const timeSinceListed = (dateString: string) => {
|
const timeSinceListed = (dateString: string) => {
|
||||||
const listedDate = new Date(dateString);
|
const listedDate = new Date(dateString.split('T')[0]);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffInMs = now.getTime() - listedDate.getTime();
|
const diffInMs = now.getTime() - listedDate.getTime();
|
||||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||||
@@ -82,7 +101,7 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
|||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<Select
|
<Select
|
||||||
value={property.property_status}
|
value={property.property_status}
|
||||||
onChange={onStatusChange}
|
onChange={handleStatusChange}
|
||||||
displayEmpty
|
displayEmpty
|
||||||
variant="standard"
|
variant="standard"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
@@ -104,21 +123,26 @@ const PropertyStatusCard: React.FC<PropertyStatusCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
|
<Box mt={2} display="flex" alignItems="center" justifyContent="space-around">
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<VisibilityIcon color="action" sx={{ mr: 1 }} />
|
<VisibilityIcon color="primary" sx={{ mr: 1 }} />
|
||||||
<Typography variant="body1">{property.views} Views</Typography>
|
<Typography variant="body1">{property.views} Views</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<FavoriteIcon color="action" sx={{ mr: 1 }} />
|
<FavoriteIcon color="primary" sx={{ mr: 1 }} />
|
||||||
) : (
|
) : (
|
||||||
<FavoriteIcon color="action" sx={{ mr: 1 }} onClick={onSavedPropertySave} />
|
<Box
|
||||||
|
component={getIcon(savedProperty)}
|
||||||
|
color="primary"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
onClick={onSavedPropertySave}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography variant="body1">{property.saves} Saves</Typography>
|
<Typography variant="body1">{property.saves} Saves</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{timeOnMarketString && (
|
{timeOnMarketString && (
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<AccessTimeIcon color="action" sx={{ mr: 1 }} />
|
<AccessTimeIcon color="primary" sx={{ mr: 1 }} />
|
||||||
<Typography variant="body1">{timeOnMarketString}</Typography>
|
<Typography variant="body1">{timeOnMarketString}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Box } from '@mui/material';
|
||||||
|
import SellerDisclosureDisplay from '../Documents/SellerDisclosureDisplay';
|
||||||
|
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
|
||||||
|
|
||||||
|
interface SellerDisclosureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
disclosureData: SellerDisclosureData;
|
||||||
|
property: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute' as 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: '80%', // Make it wider to accommodate the display
|
||||||
|
maxWidth: '900px',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '2px solid #000',
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SellerDisclosureDialog: React.FC<SellerDisclosureDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-labelledby="seller-disclosure-modal-title"
|
||||||
|
aria-describedby="seller-disclosure-modal-description"
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<SellerDisclosureDisplay disclosureData={disclosureData} property={property} />
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerDisclosureDialog;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Typography, Card, CardContent, Button, Grid } from '@mui/material';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import SellerDisclosureDialog from './SellerDisclosureDialog';
|
||||||
|
import { SellerDisclosureData, Property } from '../Documents/SellerDisclosureDisplay';
|
||||||
|
|
||||||
|
interface SellerInformationCardProps {
|
||||||
|
sellerDisclosureExists: boolean;
|
||||||
|
onSendMessage: () => void;
|
||||||
|
disclosureData?: SellerDisclosureData;
|
||||||
|
property?: Property;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SellerInformationCard: React.FC<SellerInformationCardProps> = ({
|
||||||
|
sellerDisclosureExists,
|
||||||
|
onSendMessage,
|
||||||
|
disclosureData,
|
||||||
|
property,
|
||||||
|
}) => {
|
||||||
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => setOpen(false);
|
||||||
|
|
||||||
|
if (accountLoading) {
|
||||||
|
return null; // Or a loading indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Seller Disclosure & Questions
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Create an account to view the seller disclosure document and message the owner with any
|
||||||
|
questions.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => navigate('/signup')}>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Seller Disclosure & Questions
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
disabled={!sellerDisclosureExists}
|
||||||
|
onClick={handleOpen}
|
||||||
|
>
|
||||||
|
{sellerDisclosureExists
|
||||||
|
? 'View Seller Disclosure'
|
||||||
|
: 'Seller Disclosure Not Available'}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button variant="contained" fullWidth onClick={onSendMessage}>
|
||||||
|
Ask a Question
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{disclosureData && property && (
|
||||||
|
<SellerDisclosureDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
disclosureData={disclosureData}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SellerInformationCard;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { SupportCaseApi } from 'types';
|
||||||
|
|
||||||
|
type CreateSupportCaseDialogProps = {
|
||||||
|
showDialog: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
createSupportCase: (supportCase: Omit<SupportCaseApi, 'id' | 'status' | 'messages' | 'created_at' | 'updated_at'>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryOptions = ['question', 'bug', 'other'];
|
||||||
|
|
||||||
|
const CreateSupportCaseDialogContent = ({
|
||||||
|
showDialog,
|
||||||
|
closeDialog,
|
||||||
|
createSupportCase,
|
||||||
|
}: CreateSupportCaseDialogProps): ReactElement => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (title && description && category) {
|
||||||
|
createSupportCase({ title, description, category });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onClose={closeDialog} fullWidth>
|
||||||
|
<DialogTitle>Create a New Support Case</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="title"
|
||||||
|
label="Title"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="description"
|
||||||
|
label="Description"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
variant="outlined"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={categoryOptions}
|
||||||
|
onChange={(event, newValue) => {
|
||||||
|
setCategory(newValue);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Category" variant="outlined" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreate} color="primary" disabled={!title || !description || !category}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSupportCaseDialogContent;
|
||||||
@@ -3,7 +3,6 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material';
|
import { Card, CardContent, CardMedia, Typography, Button, Box, Rating } from '@mui/material';
|
||||||
import { VendorCategory } from 'types';
|
import { VendorCategory } from 'types';
|
||||||
|
|
||||||
|
|
||||||
interface VendorCategoryCardProps {
|
interface VendorCategoryCardProps {
|
||||||
category: VendorCategory;
|
category: VendorCategory;
|
||||||
onSelectCategory: (categoryId: string) => void;
|
onSelectCategory: (categoryId: string) => void;
|
||||||
@@ -12,12 +11,7 @@ interface VendorCategoryCardProps {
|
|||||||
const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => {
|
const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSelectCategory }) => {
|
||||||
return (
|
return (
|
||||||
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Card sx={{ maxWidth: 345, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<CardMedia
|
<CardMedia component="img" height="140" image={category.imageUrl} alt={category.name} />
|
||||||
component="img"
|
|
||||||
height="140"
|
|
||||||
image={category.imageUrl}
|
|
||||||
alt={category.name}
|
|
||||||
/>
|
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
<Typography gutterBottom variant="h5" component="div">
|
<Typography gutterBottom variant="h5" component="div">
|
||||||
{category.name}
|
{category.name}
|
||||||
@@ -30,13 +24,19 @@ const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSel
|
|||||||
{category.categoryRating && (
|
{category.categoryRating && (
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<Rating value={category.categoryRating} readOnly precision={0.5} size="small" />
|
<Rating value={category.categoryRating} readOnly precision={0.5} size="small" />
|
||||||
<Typography variant="caption" sx={{ ml: 0.5 }}>({category.categoryRating.toFixed(1)})</Typography>
|
<Typography variant="caption" sx={{ ml: 0.5 }}>
|
||||||
|
({category.categoryRating.toFixed(1)})
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Box sx={{ p: 2, pt: 0 }}>
|
<Box sx={{ p: 2, pt: 0 }}>
|
||||||
<Button size="small" onClick={() => onSelectCategory(category.id)}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSelectCategory(category.id)}
|
||||||
|
disabled={category.numVendors == 0}
|
||||||
|
>
|
||||||
View Vendors
|
View Vendors
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -44,4 +44,4 @@ const VendorCategoryCard: React.FC<VendorCategoryCardProps> = ({ category, onSel
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VendorCategoryCard;
|
export default VendorCategoryCard;
|
||||||
|
|||||||
@@ -21,19 +21,23 @@ interface VendorDetailProps {
|
|||||||
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
|
const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account, accountLoading } = useContext(AccountContext);
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
const createMessage = () => {
|
const createMessage = async () => {
|
||||||
// First see if there is one already
|
// First see if there is one already
|
||||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.get(
|
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
|
||||||
`/conversations/?vendor=${vendor.id}`,
|
`/conversations/?vendor=${vendor.id}`,
|
||||||
);
|
);
|
||||||
if (data === undefined) {
|
if (data.length === 0) {
|
||||||
const { data }: AxiosResponse<ConverationAPI[]> = axiosInstance.post(`/conversations/`, {
|
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.post(
|
||||||
property_owner: account?.id,
|
`/conversations/`,
|
||||||
vendor: vendor.id,
|
{
|
||||||
});
|
property_owner: account?.id,
|
||||||
|
vendor: vendor.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
navigate(`/conversations/?selectedConversation=${data[0].id}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/conversations/?selectedConversation=${data[0].id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate('/messages');
|
|
||||||
};
|
};
|
||||||
if (!vendor) {
|
if (!vendor) {
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +73,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
|
|||||||
return (
|
return (
|
||||||
<Paper elevation={3} sx={{ p: 2 }}>
|
<Paper elevation={3} sx={{ p: 2 }}>
|
||||||
<Grid container sx={{ minHeight: '100%' }}>
|
<Grid container sx={{ minHeight: '100%' }}>
|
||||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box display="flex" alignItems="center" mb={2}>
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={vendor.vendorImageUrl}
|
src={vendor.vendorImageUrl}
|
||||||
@@ -132,7 +136,7 @@ const VendorDetail: React.FC<VendorDetailProps> = ({ vendor, showMessageBtn }) =
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={6} sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -141,8 +141,11 @@ function WebSocketProvider({ children }: WebSocketProviderProps) {
|
|||||||
ws.current.close();
|
ws.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wsUrl = new URL(import.meta.env.VITE_API_URL || 'ws://127.0.0.1:8010/ws/');
|
||||||
|
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
|
||||||
|
|
||||||
ws.current = new WebSocket(
|
ws.current = new WebSocket(
|
||||||
`ws://127.0.0.1:8010/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
`${wsUrl.origin}/ws/chat/${account.id}/?token=${localStorage.getItem('access_token')}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
ws.current.onopen = () => {
|
ws.current.onopen = () => {
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ const attorneyNavItems: NavItem[] = [
|
|||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Conversations',
|
title: 'Conversations',
|
||||||
path: '/conversations',
|
path: '/conversations',
|
||||||
@@ -37,6 +44,13 @@ const attorneyNavItems: NavItem[] = [
|
|||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default attorneyNavItems;
|
export default attorneyNavItems;
|
||||||
|
|||||||
@@ -25,21 +25,21 @@ const basicNavItems: NavItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
path: '/property/search',
|
path: '/property-search',
|
||||||
icon: 'ph:magnifying-glass',
|
icon: 'ph:magnifying-glass',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Education',
|
title: 'Education',
|
||||||
path: '/education',
|
path: '/upgrade',
|
||||||
icon: 'ph:student',
|
icon: 'ph:student',
|
||||||
active: false,
|
active: false,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Vendors',
|
title: 'Vendors',
|
||||||
path: '/vendors',
|
path: '/upgrade',
|
||||||
icon: 'ph:storefront',
|
icon: 'ph:storefront',
|
||||||
active: false,
|
active: false,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
@@ -48,27 +48,19 @@ const basicNavItems: NavItem[] = [
|
|||||||
title: 'Messages',
|
title: 'Messages',
|
||||||
path: '',
|
path: '',
|
||||||
icon: 'ph:chat-circle-dots',
|
icon: 'ph:chat-circle-dots',
|
||||||
active: true,
|
active: false,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
sublist: [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Offers',
|
title: 'Documents',
|
||||||
path: 'offers',
|
path: 'documents',
|
||||||
icon: 'ph:certificate',
|
icon: 'ph:folder',
|
||||||
active: true,
|
active: false,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Conversations',
|
title: 'Conversations',
|
||||||
path: 'conversations',
|
path: 'upgrade',
|
||||||
icon: 'ph:chat-circle-dots',
|
|
||||||
active: true,
|
|
||||||
collapsible: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
title: 'Bids',
|
|
||||||
path: 'bids',
|
|
||||||
icon: 'ph:chat-circle-dots',
|
icon: 'ph:chat-circle-dots',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
@@ -84,30 +76,37 @@ const basicNavItems: NavItem[] = [
|
|||||||
sublist: [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Mortgage Calculator',
|
title: 'Mortgage Calculator',
|
||||||
path: '/mortgage-calculator',
|
path: 'mortgage-calculator',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Amortization Table',
|
title: 'Amortization Table',
|
||||||
path: '/amoritization-table',
|
path: 'amoritization-table',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Home Affordability',
|
title: 'Home Affordability',
|
||||||
path: '/home-affordability',
|
path: 'home-affordability',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Net Terms Sheet',
|
title: 'Net Terms Sheet',
|
||||||
path: '/net-terms-sheet',
|
path: 'net-terms-sheet',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default basicNavItems;
|
export default basicNavItems;
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { NavItem } from 'types';
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
path: '/',
|
path: '/dashboard',
|
||||||
icon: 'ion:home-sharp',
|
icon: 'ion:home-sharp',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
sublist: [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
path: '/',
|
path: '/dasboard',
|
||||||
active: false,
|
active: false,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@ const navItems: NavItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
path: '/property/search',
|
path: '/property-search',
|
||||||
icon: 'ph:magnifying-glass',
|
icon: 'ph:magnifying-glass',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
@@ -52,9 +52,9 @@ const navItems: NavItem[] = [
|
|||||||
collapsible: true,
|
collapsible: true,
|
||||||
sublist: [
|
sublist: [
|
||||||
{
|
{
|
||||||
title: 'Offers',
|
title: 'Bids',
|
||||||
path: 'offers',
|
path: 'bids',
|
||||||
icon: 'ph:certificate',
|
icon: 'ph:chat-circle-dots',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
@@ -67,8 +67,8 @@ const navItems: NavItem[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Bids',
|
title: 'Documents',
|
||||||
path: 'bids',
|
path: 'documents',
|
||||||
icon: 'ph:chat-circle-dots',
|
icon: 'ph:chat-circle-dots',
|
||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
@@ -108,6 +108,13 @@ const navItems: NavItem[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default navItems;
|
export default navItems;
|
||||||
|
|||||||
13
ditch-the-agent/src/data/public-nav-items.ts
Normal file
13
ditch-the-agent/src/data/public-nav-items.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NavItem } from 'types';
|
||||||
|
|
||||||
|
const publicNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default publicNavItems;
|
||||||
@@ -23,6 +23,13 @@ const vendorNavItems: NavItem[] = [
|
|||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
path: '/property-search',
|
||||||
|
icon: 'ph:magnifying-glass',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Conversations',
|
title: 'Conversations',
|
||||||
path: '/conversations',
|
path: '/conversations',
|
||||||
@@ -37,6 +44,13 @@ const vendorNavItems: NavItem[] = [
|
|||||||
active: true,
|
active: true,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Support',
|
||||||
|
path: '/support',
|
||||||
|
icon: 'ph:question',
|
||||||
|
active: true,
|
||||||
|
collapsible: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default vendorNavItems;
|
export default vendorNavItems;
|
||||||
|
|||||||
@@ -1,149 +1,153 @@
|
|||||||
import { ReactElement, useState } from 'react';
|
import { ReactElement, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Collapse,
|
Collapse,
|
||||||
LinkTypeMap,
|
LinkTypeMap,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { NavItem } from 'data/nav-items';
|
import { NavItem } from 'data/nav-items';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
navItem: NavItem;
|
navItem: NavItem;
|
||||||
Link: OverridableComponent<LinkTypeMap>;
|
Link: OverridableComponent<LinkTypeMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
const NavButton = ({ navItem, Link }: NavItemProps): ReactElement => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
|
const [nestedChecked, setNestedChecked] = useState<boolean[]>([]);
|
||||||
|
|
||||||
const handleNestedChecked = (index: any, value: boolean) => {
|
const handleNestedChecked = (index: any, value: boolean) => {
|
||||||
const updatedBooleanArray = [...nestedChecked];
|
const updatedBooleanArray = [...nestedChecked];
|
||||||
updatedBooleanArray[index] = value;
|
updatedBooleanArray[index] = value;
|
||||||
setNestedChecked(updatedBooleanArray);
|
setNestedChecked(updatedBooleanArray);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const color = pathname === navItem.path ? 'common.white' : 'text.secondary';
|
||||||
<ListItem
|
const backgroundColor = pathname === navItem.path ? 'primary.main' : '';
|
||||||
sx={{
|
const hoverBackgroundColor = pathname === navItem.path ? 'primary.main' : 'action.focus';
|
||||||
my: 1.25,
|
|
||||||
borderRadius: 2,
|
return (
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
<ListItem
|
||||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
sx={{
|
||||||
'&:hover': {
|
my: 1.25,
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
borderRadius: 2,
|
||||||
opacity: 1.5,
|
backgroundColor: { backgroundColor },
|
||||||
},
|
color: { color },
|
||||||
}}
|
'&:hover': {
|
||||||
>
|
backgroundColor: { hoverBackgroundColor },
|
||||||
{navItem.collapsible ? (
|
opacity: 1.5,
|
||||||
<>
|
},
|
||||||
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
|
}}
|
||||||
<ListItemIcon>
|
>
|
||||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
{navItem.collapsible ? (
|
||||||
</ListItemIcon>
|
<>
|
||||||
<ListItemText>{navItem.title}</ListItemText>
|
<ListItemButton LinkComponent={Link} onClick={() => setChecked(!checked)}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{navItem.collapsible &&
|
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||||
(checked ? (
|
</ListItemIcon>
|
||||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
<ListItemText>{navItem.title}</ListItemText>
|
||||||
) : (
|
<ListItemIcon>
|
||||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
{navItem.collapsible &&
|
||||||
))}
|
(checked ? (
|
||||||
</ListItemIcon>
|
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||||
</ListItemButton>
|
) : (
|
||||||
<Collapse in={checked}>
|
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||||
<List>
|
))}
|
||||||
{navItem.sublist?.map((subListItem: any, idx: number) => (
|
</ListItemIcon>
|
||||||
<ListItem
|
</ListItemButton>
|
||||||
key={idx}
|
<Collapse in={checked}>
|
||||||
sx={{
|
<List>
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
{navItem.sublist?.map((subListItem: any, idx: number) => (
|
||||||
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
<ListItem
|
||||||
'&:hover': {
|
key={idx}
|
||||||
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
sx={{
|
||||||
opacity: 1.5,
|
backgroundColor: pathname === navItem.path ? 'primary.main' : '',
|
||||||
},
|
color: pathname === navItem.path ? 'common.white' : 'text.secondary',
|
||||||
}}
|
'&:hover': {
|
||||||
>
|
backgroundColor: pathname === navItem.path ? 'primary.main' : 'action.focus',
|
||||||
{subListItem.collapsible ? (
|
opacity: 1.5,
|
||||||
<>
|
},
|
||||||
<ListItemButton
|
}}
|
||||||
LinkComponent={Link}
|
>
|
||||||
onClick={() => {
|
{subListItem.collapsible ? (
|
||||||
handleNestedChecked(idx, !nestedChecked[idx]);
|
<>
|
||||||
}}
|
<ListItemButton
|
||||||
>
|
LinkComponent={Link}
|
||||||
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
|
onClick={() => {
|
||||||
<ListItemIcon>
|
handleNestedChecked(idx, !nestedChecked[idx]);
|
||||||
{subListItem.collapsible &&
|
}}
|
||||||
(nestedChecked[idx] ? (
|
>
|
||||||
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
<ListItemText sx={{ ml: 3.5 }}>{subListItem.title}</ListItemText>
|
||||||
) : (
|
<ListItemIcon>
|
||||||
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
{subListItem.collapsible &&
|
||||||
))}
|
(nestedChecked[idx] ? (
|
||||||
</ListItemIcon>
|
<IconifyIcon icon="mingcute:up-fill" width={1} height={1} />
|
||||||
</ListItemButton>
|
) : (
|
||||||
<Collapse in={nestedChecked[idx]}>
|
<IconifyIcon icon="mingcute:down-fill" width={1} height={1} />
|
||||||
<List>
|
))}
|
||||||
{subListItem?.sublist?.map(
|
</ListItemIcon>
|
||||||
(nestedSubListItem: any, nestedIdx: number) => (
|
</ListItemButton>
|
||||||
<ListItem key={nestedIdx}>
|
<Collapse in={nestedChecked[idx]}>
|
||||||
<ListItemButton
|
<List>
|
||||||
LinkComponent={Link}
|
{subListItem?.sublist?.map(
|
||||||
href={
|
(nestedSubListItem: any, nestedIdx: number) => (
|
||||||
navItem.path !== '/'
|
<ListItem key={nestedIdx}>
|
||||||
? navItem.path +
|
<ListItemButton
|
||||||
'/' +
|
LinkComponent={Link}
|
||||||
subListItem.path +
|
href={
|
||||||
'/' +
|
navItem.path !== '/'
|
||||||
nestedSubListItem.path
|
? navItem.path +
|
||||||
: nestedSubListItem.path
|
'/' +
|
||||||
}
|
subListItem.path +
|
||||||
>
|
'/' +
|
||||||
<ListItemText sx={{ ml: 5 }}>
|
nestedSubListItem.path
|
||||||
{nestedSubListItem.title}
|
: nestedSubListItem.path
|
||||||
</ListItemText>
|
}
|
||||||
</ListItemButton>
|
>
|
||||||
</ListItem>
|
<ListItemText sx={{ ml: 5 }}>
|
||||||
),
|
{nestedSubListItem.title}
|
||||||
)}
|
</ListItemText>
|
||||||
</List>
|
</ListItemButton>
|
||||||
</Collapse>
|
</ListItem>
|
||||||
</>
|
),
|
||||||
) : (
|
)}
|
||||||
<ListItemButton
|
</List>
|
||||||
LinkComponent={Link}
|
</Collapse>
|
||||||
href={navItem.path + '/' + subListItem.path}
|
</>
|
||||||
>
|
) : (
|
||||||
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
|
<ListItemButton
|
||||||
</ListItemButton>
|
LinkComponent={Link}
|
||||||
)}
|
href={navItem.path + '/' + subListItem.path}
|
||||||
</ListItem>
|
>
|
||||||
))}
|
<ListItemText sx={{ ml: 3 }}>{subListItem.title}</ListItemText>
|
||||||
</List>
|
</ListItemButton>
|
||||||
</Collapse>
|
)}
|
||||||
</>
|
</ListItem>
|
||||||
) : (
|
))}
|
||||||
<ListItemButton
|
</List>
|
||||||
LinkComponent={Link}
|
</Collapse>
|
||||||
href={navItem.path}
|
</>
|
||||||
sx={{ opacity: navItem.active ? 1 : 0.6 }}
|
) : (
|
||||||
>
|
<ListItemButton
|
||||||
<ListItemIcon>
|
LinkComponent={Link}
|
||||||
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
href={navItem.path}
|
||||||
</ListItemIcon>
|
sx={{ opacity: navItem.active ? 1 : 0.6 }}
|
||||||
<ListItemText>{navItem.title}</ListItemText>
|
>
|
||||||
</ListItemButton>
|
<ListItemIcon>
|
||||||
)}
|
<IconifyIcon icon={navItem.icon as string} width={1} height={1} />
|
||||||
</ListItem>
|
</ListItemIcon>
|
||||||
);
|
<ListItemText>{navItem.title}</ListItemText>
|
||||||
};
|
</ListItemButton>
|
||||||
|
)}
|
||||||
export default NavButton;
|
</ListItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavButton;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import vendorNavItems from 'data/vendor-nav-items.js';
|
|||||||
import basicNavItems from 'data/basic-nav-items.js';
|
import basicNavItems from 'data/basic-nav-items.js';
|
||||||
import { NavItem } from 'types.js';
|
import { NavItem } from 'types.js';
|
||||||
import attorneyNavItems from 'data/attorney-nav-items.js';
|
import attorneyNavItems from 'data/attorney-nav-items.js';
|
||||||
|
import publicNavItems from 'data/public-nav-items.js';
|
||||||
|
|
||||||
const Sidebar = (): ReactElement => {
|
const Sidebar = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,6 +44,8 @@ const Sidebar = (): ReactElement => {
|
|||||||
} else if (account.user_type === 'attorney') {
|
} else if (account.user_type === 'attorney') {
|
||||||
nav_items = attorneyNavItems;
|
nav_items = attorneyNavItems;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
nav_items = publicNavItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
|
|||||||
26
ditch-the-agent/src/layouts/public-layout/Footer.tsx
Normal file
26
ditch-the-agent/src/layouts/public-layout/Footer.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Link, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent={{ xs: 'center' }}
|
||||||
|
ml={{ xs: 3.75, lg: 34.75 }}
|
||||||
|
mr={3.75}
|
||||||
|
my={3.75}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
||||||
|
<Link
|
||||||
|
href="https://ditchtheagent.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
|
||||||
|
>
|
||||||
|
Ditch The Agent
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
39
ditch-the-agent/src/layouts/public-layout/Topbar/index.tsx
Normal file
39
ditch-the-agent/src/layouts/public-layout/Topbar/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { AppBar, Button, Stack, Toolbar, Link } from '@mui/material';
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Image from 'components/base/Image';
|
||||||
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
|
|
||||||
|
const Topbar = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSignIn = () => {
|
||||||
|
navigate('/authentication/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
p: 3.75,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link href="/">
|
||||||
|
<Image src={logo} width={40} height={40} />
|
||||||
|
</Link>
|
||||||
|
<Stack direction="row" gap={1}>
|
||||||
|
<Button variant="contained" onClick={handleSignIn}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Topbar;
|
||||||
40
ditch-the-agent/src/layouts/public-layout/index.tsx
Normal file
40
ditch-the-agent/src/layouts/public-layout/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { PropsWithChildren, ReactElement } from 'react';
|
||||||
|
import { Stack, Toolbar, Typography, Link } from '@mui/material';
|
||||||
|
import Topbar from './Topbar';
|
||||||
|
import Footer from './Footer';
|
||||||
|
|
||||||
|
const PublicLayout = ({ children }: PropsWithChildren): ReactElement => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack minHeight="100vh" bgcolor="background.default">
|
||||||
|
<Topbar />
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
pt: 12,
|
||||||
|
width: 1,
|
||||||
|
pb: 0,
|
||||||
|
alignItems: 'start',
|
||||||
|
flex: '1 0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Toolbar>
|
||||||
|
<Stack direction="row" justifyContent={{ xs: 'center' }} my={3.75}>
|
||||||
|
<Typography variant="subtitle2" fontFamily={'Poppins'} color="text.primary">
|
||||||
|
<Link
|
||||||
|
href="https://ditchtheagent.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
sx={{ color: 'background.paper', '&:hover': { color: 'secondary.main' } }}
|
||||||
|
>
|
||||||
|
Ditch The Agent
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{/*<Footer />*/}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicLayout;
|
||||||
@@ -56,7 +56,7 @@ const BidsPage: React.FC = () => {
|
|||||||
|
|
||||||
<Grid container spacing={3} sx={{ mt: 3 }}>
|
<Grid container spacing={3} sx={{ mt: 3 }}>
|
||||||
{bids.map((bid) => (
|
{bids.map((bid) => (
|
||||||
<Grid item xs={12} key={bid.id}>
|
<Grid size={{ xs: 12 }} key={bid.id}>
|
||||||
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
|
<BidCard bid={bid} onDelete={handleDeleteBid} isOwner={true} />
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
0
ditch-the-agent/src/pages/Documents/Documents.tsx
Normal file
0
ditch-the-agent/src/pages/Documents/Documents.tsx
Normal file
@@ -1,31 +1,85 @@
|
|||||||
import { ReactElement, useEffect, useState } from 'react';
|
import { ReactElement, useEffect, useState } from 'react';
|
||||||
import {axiosInstance} from '../../axiosApi'
|
import { axiosInstance } from '../../axiosApi';
|
||||||
import DashboardTemplate from 'components/DasboardTemplate';
|
import DashboardTemplate from 'components/DasboardTemplate';
|
||||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||||
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
|
import VideoPlayer from 'components/sections/dashboard/Home/Education/VideoPlayer';
|
||||||
import { GenericCategory, GenericItem, VideoAPI, VideoCategory, VideoItem, VideoProgressAPI } from 'types';
|
import {
|
||||||
|
GenericCategory,
|
||||||
|
GenericItem,
|
||||||
|
VideoAPI,
|
||||||
|
VideoCategory,
|
||||||
|
VideoItem,
|
||||||
|
VideoProgressAPI,
|
||||||
|
} from 'types';
|
||||||
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
|
import VideoCategoryCard from 'components/sections/dashboard/Home/Education/VideoCategoryCard';
|
||||||
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
|
import VideoListItem from 'components/sections/dashboard/Home/Education/VideoListItem';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
|
||||||
const Education = (): ReactElement => {
|
const Education = (): ReactElement => {
|
||||||
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
|
const [allVideos, setAllVideos] = useState<VideoItem[]>([]);
|
||||||
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
|
const [videoCategories, setVideoCategories] = useState<VideoCategory[]>([]);
|
||||||
|
|
||||||
// Simulate fetching data from backend
|
const updateVideoCategories = (videos: VideoItem[]) => {
|
||||||
let fetchedVideos: VideoItem[] = []
|
const categoryMap = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; total: number; completed: number; description: string; imageUrl: string }
|
||||||
|
>();
|
||||||
|
// Populate category details (you might hardcode descriptions/images or fetch them)
|
||||||
|
// For demonstration, let's assume a default image and generate a description
|
||||||
|
const defaultCategoryImages: { [key: string]: string } = {
|
||||||
|
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
|
||||||
|
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
|
||||||
|
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
|
||||||
|
// Add more as needed
|
||||||
|
};
|
||||||
|
videos.forEach((video) => {
|
||||||
|
const categoryId = video.categoryId;
|
||||||
|
if (!categoryMap.has(categoryId)) {
|
||||||
|
let categoryName = video.category;
|
||||||
|
categoryMap.set(categoryId, {
|
||||||
|
name: categoryName,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
description: `Explore ${video.category} concepts and build your skills.`,
|
||||||
|
imageUrl:
|
||||||
|
defaultCategoryImages[video.category] ||
|
||||||
|
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const categoryData = categoryMap.get(categoryId)!;
|
||||||
|
categoryData.total += 1;
|
||||||
|
if (video.status === 'completed') {
|
||||||
|
categoryData.completed += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(
|
||||||
|
([id, data]) => ({
|
||||||
|
id, // id is the category name here
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
totalVideos: data.total,
|
||||||
|
completedVideos: data.completed,
|
||||||
|
categoryProgress: data.total > 0 ? (data.completed / data.total) * 100 : 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setVideoCategories(processedCategories);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate fetching data from backend
|
||||||
|
let fetchedVideos: VideoItem[] = [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// In a real app, you'd make an API call here
|
// In a real app, you'd make an API call here
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
// Replace with your actual API call
|
// Replace with your actual API call
|
||||||
try{
|
try {
|
||||||
|
const { data }: AxiosResponse<VideoProgressAPI[]> =
|
||||||
const {data,}: AxiosResponse<VideoProgressAPI[]> = await axiosInstance.get('/videos/progress/')
|
await axiosInstance.get('/videos/progress/');
|
||||||
if(data.length > 0){
|
if (data.length > 0) {
|
||||||
fetchedVideos = data.map(item => {
|
fetchedVideos = data.map((item) => {
|
||||||
console.log(item)
|
|
||||||
return {
|
return {
|
||||||
id: String(item.video.id),
|
id: String(item.video.id),
|
||||||
progress_id: item.id,
|
progress_id: item.id,
|
||||||
@@ -37,75 +91,45 @@ const Education = (): ReactElement => {
|
|||||||
progress: item.progress,
|
progress: item.progress,
|
||||||
videoUrl: item.video.link,
|
videoUrl: item.video.link,
|
||||||
duration: item.video.duration,
|
duration: item.video.duration,
|
||||||
}
|
};
|
||||||
})
|
|
||||||
setAllVideos(fetchedVideos)
|
|
||||||
}
|
|
||||||
|
|
||||||
}catch (error){
|
|
||||||
console.log('there was an error', error)
|
|
||||||
}
|
|
||||||
const categoryMap = new Map<string, {name: string; total: number; completed: number; description: string; imageUrl: string; }>();
|
|
||||||
|
|
||||||
// Populate category details (you might hardcode descriptions/images or fetch them)
|
|
||||||
// For demonstration, let's assume a default image and generate a description
|
|
||||||
const defaultCategoryImages: { [key: string]: string } = {
|
|
||||||
'Frontend Development': 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Frontend',
|
|
||||||
'Backend Development': 'https://via.placeholder.com/150/3366FF/FFFFFF?text=Backend',
|
|
||||||
'Database Management': 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Database',
|
|
||||||
// Add more as needed
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchedVideos.forEach(video => {
|
|
||||||
const categoryId = video.categoryId;
|
|
||||||
if (!categoryMap.has(categoryId)) {
|
|
||||||
let categoryName = video.category;
|
|
||||||
categoryMap.set(categoryId, {
|
|
||||||
name: categoryName,
|
|
||||||
total: 0,
|
|
||||||
completed: 0,
|
|
||||||
description: `Explore ${video.category} concepts and build your skills.`,
|
|
||||||
imageUrl: defaultCategoryImages[video.category] || 'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
|
||||||
});
|
});
|
||||||
|
setAllVideos(fetchedVideos);
|
||||||
}
|
}
|
||||||
const categoryData = categoryMap.get(categoryId)!;
|
} catch (error) {
|
||||||
categoryData.total += 1;
|
console.log('there was an error', error);
|
||||||
if (video.status === 'completed') {
|
}
|
||||||
categoryData.completed += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedCategories: VideoCategory[] = Array.from(categoryMap.entries()).map(([id, data]) => ({
|
|
||||||
id, // id is the category name here
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
imageUrl: data.imageUrl,
|
|
||||||
totalVideos: data.total,
|
|
||||||
completedVideos: data.completed,
|
|
||||||
categoryProgress: (data.total > 0) ? (data.completed / data.total) * 100 : 0,
|
|
||||||
}));
|
|
||||||
setVideoCategories(processedCategories);
|
|
||||||
|
|
||||||
|
updateVideoCategories(fetchedVideos);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateVideoItem = async (
|
||||||
|
time: number,
|
||||||
|
completed: boolean,
|
||||||
|
progress: number,
|
||||||
|
videoId: number,
|
||||||
// const handleSelectCategory = (categoryName: string) => {
|
) => {
|
||||||
// setSelectedCategory(categoryName);
|
const newPossibleStatus: string = completed ? 'complete' : 'in-progress';
|
||||||
// };
|
// we already saved the data to the backend, so just update it on the Frontend
|
||||||
|
const updatedVideoItems: VideoItem[] = allVideos.map((item) => {
|
||||||
|
if (item.id === videoId) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
progress: progress,
|
||||||
|
// if the video is already completed, we don't want to reset it
|
||||||
|
status: item.status === 'completed' ? item.status : newPossibleStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
updateVideoCategories(updatedVideoItems);
|
||||||
|
setAllVideos(updatedVideoItems);
|
||||||
|
};
|
||||||
|
|
||||||
// const handleBackToCategories = () => {
|
return (
|
||||||
// setSelectedCategory(null);
|
<DashboardTemplate<VideoCategory, VideoItem>
|
||||||
// };
|
|
||||||
|
|
||||||
return(
|
|
||||||
<DashboardTemplate<VideoCategory, VideoItem>
|
|
||||||
pageTitle="Educational Videos"
|
pageTitle="Educational Videos"
|
||||||
data={{ categories: videoCategories, items: allVideos }}
|
data={{ categories: videoCategories, items: allVideos }}
|
||||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||||
@@ -123,39 +147,22 @@ const Education = (): ReactElement => {
|
|||||||
items={itemsInSelectedCategory}
|
items={itemsInSelectedCategory}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
renderListItem={(item, isSelected, onSelect) => (
|
renderListItem={(item, isSelected, onSelect) => (
|
||||||
<VideoListItem video={item as VideoItem} isSelected={isSelected}
|
<VideoListItem
|
||||||
onSelect={() => {
|
video={item as VideoItem}
|
||||||
console.log('selecting')
|
isSelected={isSelected}
|
||||||
onSelect(item.id)}
|
onSelect={() => {
|
||||||
} />
|
console.log('selecting');
|
||||||
|
onSelect(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
renderItemDetail={(item) => (
|
renderItemDetail={(item) => (
|
||||||
<VideoPlayer video={item as VideoItem} />
|
<VideoPlayer video={item as VideoItem} updateVideoItem={updateVideoItem} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
)
|
export default Education;
|
||||||
// (return(
|
|
||||||
// <Container maxWidth="lg" sx={{ mt: 4 }}>
|
|
||||||
// <Typography variant="h4" component="h1" gutterBottom>
|
|
||||||
// Educational Videos
|
|
||||||
// </Typography>
|
|
||||||
|
|
||||||
// {selectedCategory ? (
|
|
||||||
// <VideoPlayerPage
|
|
||||||
// categoryName={selectedCategory}
|
|
||||||
// videos={videos.filter(video => video.category === selectedCategory)}
|
|
||||||
// onBack={handleBackToCategories}
|
|
||||||
// // You'll need to pass functions for updating video progress back to Dashboard
|
|
||||||
// // For simplicity, we'll assume updates happen on the backend or in a global state
|
|
||||||
// />
|
|
||||||
// ) : (
|
|
||||||
// <CategoryGrid categories={categories} onSelectCategory={handleSelectCategory} />
|
|
||||||
// )}
|
|
||||||
// </Container>
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Education;
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { drawerWidth } from 'layouts/main-layout';
|
import { drawerWidth } from 'layouts/main-layout';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,11 @@ import { axiosInstance } from '../../axiosApi';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Autocomplete,
|
||||||
List,
|
List,
|
||||||
Grid,
|
Grid,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -36,6 +42,7 @@ import { AxiosResponse } from 'axios';
|
|||||||
import { AccountContext } from 'contexts/AccountContext';
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
|
import { DefaultDataProvider } from 'echarts/types/src/data/helper/dataProvider.js';
|
||||||
import { formatTimestamp } from 'utils';
|
import { formatTimestamp } from 'utils';
|
||||||
|
import CreateConversationDialogContent from 'components/sections/dashboard/Home/Messages/CreateConversationDialogContent';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -53,14 +60,38 @@ interface Conversation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Messages = (): ReactElement => {
|
const Messages = (): ReactElement => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
||||||
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
|
const [openCreateConversationDialog, setOpenCreateConversationDialog] = useState(false);
|
||||||
|
const [vendors, setVendors] = useState<VendorItem[]>([]);
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<VendorItem | null>(null);
|
||||||
|
|
||||||
// Auto-scroll to the bottom of the messages when they update
|
// Auto-scroll to the bottom of the messages when they update
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedConversation = searchParams.get('selectedConversation');
|
||||||
|
if (selectedConversation) {
|
||||||
|
console.log(selectedConversation);
|
||||||
|
setSelectedConversationId(parseInt(selectedConversation, 10));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVendors = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<VendorItem[]> = await axiosInstance.get('/vendors/');
|
||||||
|
setVendors(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch vendors', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchVendors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConversations = async () => {
|
const fetchConversations = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +134,72 @@ const Messages = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
|
}, [selectedConversationId, conversations]); // Re-run when conversation changes or messages update
|
||||||
|
|
||||||
|
const handleOpenCreateConversationDialog = () => {
|
||||||
|
setOpenCreateConversationDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCreateConversationDialog = () => {
|
||||||
|
setOpenCreateConversationDialog(false);
|
||||||
|
setSelectedVendor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateConversation = async () => {
|
||||||
|
if (!selectedVendor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log(selectedVendor);
|
||||||
|
|
||||||
|
// first make sure that there isn't already a conversation
|
||||||
|
const { data }: AxiosResponse<ConverationAPI[]> = await axiosInstance.get(
|
||||||
|
`/conversations/?vendor=${selectedVendor.user.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
const { data }: AxiosResponse<ConverationAPI> = await axiosInstance.post(
|
||||||
|
`/conversations/`,
|
||||||
|
{
|
||||||
|
property_owner: account?.id,
|
||||||
|
vendor: selectedVendor.user.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(data);
|
||||||
|
const newConversationData = data;
|
||||||
|
console.log(newConversationData);
|
||||||
|
|
||||||
|
const lastMessageSnippet: string =
|
||||||
|
newConversationData.messages.length > 0
|
||||||
|
? newConversationData.messages[newConversationData.messages.length - 1].text
|
||||||
|
: '';
|
||||||
|
const messages: Message[] = newConversationData.messages.map((message: any) => {
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
content: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
senderId:
|
||||||
|
message.sender === newConversationData.property_owner.user.id ? 'owner' : 'vendor',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const newConversation: Conversation = {
|
||||||
|
id: newConversationData.id,
|
||||||
|
withName: selectedVendor.business_name,
|
||||||
|
lastMessageTimestamp: newConversationData.updated_at,
|
||||||
|
lastMessageSnippet: lastMessageSnippet,
|
||||||
|
messages: messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
setConversations((prev) => [newConversation, ...prev]);
|
||||||
|
setSelectedConversationId(newConversation.id);
|
||||||
|
} else {
|
||||||
|
setSelectedConversationId(data[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseCreateConversationDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create conversation', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
|
const selectedConversation = conversations.find((conv) => conv.id === selectedConversationId);
|
||||||
|
|
||||||
// Handle sending a new message
|
// Handle sending a new message
|
||||||
@@ -147,19 +244,16 @@ const Messages = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
|
||||||
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={3}
|
elevation={3}
|
||||||
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Grid container sx={{ height: '100%' }}>
|
<Grid container sx={{ height: '100%', width: '100%' }}>
|
||||||
{/* Left Panel: Conversation List */}
|
{/* Left Panel: Conversation List */}
|
||||||
<Grid
|
<Grid
|
||||||
item
|
size={{ xs: 12, md: 4 }}
|
||||||
xs={12}
|
|
||||||
md={4}
|
|
||||||
sx={{
|
sx={{
|
||||||
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -172,8 +266,13 @@ const Messages = (): ReactElement => {
|
|||||||
Conversations
|
Conversations
|
||||||
</Typography>
|
</Typography>
|
||||||
{account?.user_type === 'property_owner' && (
|
{account?.user_type === 'property_owner' && (
|
||||||
<Button variant="contained" color="primary" sx={{ ml: 'auto' }}>
|
<Button
|
||||||
New Conversation
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 'auto' }}
|
||||||
|
onClick={handleOpenCreateConversationDialog}
|
||||||
|
>
|
||||||
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -234,7 +333,7 @@ const Messages = (): ReactElement => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Right Panel: Conversation Detail */}
|
{/* Right Panel: Conversation Detail */}
|
||||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{selectedConversation ? (
|
{selectedConversation ? (
|
||||||
<>
|
<>
|
||||||
{/* Conversation Header */}
|
{/* Conversation Header */}
|
||||||
@@ -355,6 +454,14 @@ const Messages = (): ReactElement => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<CreateConversationDialogContent
|
||||||
|
showDialog={openCreateConversationDialog}
|
||||||
|
closeDialog={handleCloseCreateConversationDialog}
|
||||||
|
createConversation={handleCreateConversation}
|
||||||
|
vendors={vendors}
|
||||||
|
setSelectedVendor={setSelectedVendor}
|
||||||
|
selectedVendor={selectedVendor}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
import { ReactElement, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { drawerWidth } from 'layouts/main-layout';
|
import { drawerWidth } from 'layouts/main-layout';
|
||||||
|
|
||||||
import { ConverationAPI, ConversationItem, GenericCategory, MessagesAPI, OfferAPI, VendorCategory, VendorItem } from 'types';
|
import {
|
||||||
|
ConverationAPI,
|
||||||
|
ConversationItem,
|
||||||
|
GenericCategory,
|
||||||
|
MessagesAPI,
|
||||||
|
OfferAPI,
|
||||||
|
VendorCategory,
|
||||||
|
VendorItem,
|
||||||
|
} from 'types';
|
||||||
import DashboardTemplate from 'components/DasboardTemplate';
|
import DashboardTemplate from 'components/DasboardTemplate';
|
||||||
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
import CategoryGridTemplate from 'components/CategoryGridTemplate';
|
||||||
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
import VendorCategoryCard from 'components/sections/dashboard/Home/Vendor/VendorCategoryCard';
|
||||||
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
||||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||||
import {axiosInstance} from '../../axiosApi'
|
import { axiosInstance } from '../../axiosApi';
|
||||||
import { Box, Container, List, Grid, ListItem, ListItemText, Typography, Paper, TextField, Button, Avatar, Stack, Accordion, AccordionActions, AccordionSummary, AccordionDetails, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
List,
|
||||||
|
Grid,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
Stack,
|
||||||
|
Accordion,
|
||||||
|
AccordionActions,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
} from '@mui/material';
|
||||||
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
|
import LocalOffer from '@mui/icons-material/ChatBubbleOutline';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
@@ -37,304 +66,348 @@ interface Offer {
|
|||||||
lastMessageTimestamp: string;
|
lastMessageTimestamp: string;
|
||||||
market_value: string;
|
market_value: string;
|
||||||
offer_value: string;
|
offer_value: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type submitOfferProps = {
|
type submitOfferProps = {
|
||||||
offer_id: number,
|
offer_id: number;
|
||||||
sender_id: number,
|
sender_id: number;
|
||||||
property_id: number,
|
property_id: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Offers = (): ReactElement => {
|
const Offers = (): ReactElement => {
|
||||||
const [offers, setOffers] = useState<Offer[]>([]);
|
const [offers, setOffers] = useState<Offer[]>([]);
|
||||||
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
|
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
|
||||||
const {account} = useContext(AccountContext)
|
const { account } = useContext(AccountContext);
|
||||||
|
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
}
|
};
|
||||||
const createOffer = async (property_id: number) => {
|
const createOffer = async (property_id: number) => {
|
||||||
console.log(account)
|
console.log(account);
|
||||||
if(account)
|
if (account) {
|
||||||
{
|
|
||||||
console.log({
|
console.log({
|
||||||
user: account.id,
|
user: account.id,
|
||||||
property: property_id,
|
property: property_id,
|
||||||
|
});
|
||||||
|
response = await axiosInstance.post(`/offers/`, {
|
||||||
})
|
|
||||||
response = await axiosInstance.post(`/offers/`,
|
|
||||||
{
|
|
||||||
user: account.id,
|
user: account.id,
|
||||||
property: property_id,
|
property: property_id,
|
||||||
|
});
|
||||||
|
|
||||||
})
|
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
|
|
||||||
|
|
||||||
setShowDialog(false)
|
|
||||||
|
|
||||||
|
setShowDialog(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
const submitOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {
|
||||||
const submitOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
response = await axiosInstance.put(`/offers/${offer_id}/`, {
|
||||||
response = await axiosInstance.put(`/offers/${offer_id}/`,
|
user: sender_id,
|
||||||
{
|
property: property_id,
|
||||||
user: sender_id,
|
status: 'submitted',
|
||||||
property: property_id,
|
});
|
||||||
status:'submitted'
|
console.log(response);
|
||||||
|
|
||||||
})
|
|
||||||
console.log(response)
|
|
||||||
|
|
||||||
// TODO: update the selectedOffer' status
|
// TODO: update the selectedOffer' status
|
||||||
const updatedOffers: Offer[] = offers.map(item => ({
|
const updatedOffers: Offer[] = offers.map((item) => ({
|
||||||
...item, // Spread operator to copy existing properties
|
...item, // Spread operator to copy existing properties
|
||||||
status: 'submitted'
|
status: 'submitted',
|
||||||
}));
|
}));
|
||||||
setOffers(updatedOffers);
|
setOffers(updatedOffers);
|
||||||
}
|
};
|
||||||
|
|
||||||
const withdrawOffer = async({offer_id, sender_id, property_id}: submitOfferProps) => {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const withdrawOffer = async ({ offer_id, sender_id, property_id }: submitOfferProps) => {};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
const fetchOffers = async () => {
|
const fetchOffers = async () => {
|
||||||
try{
|
try {
|
||||||
const {data, }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/')
|
const { data }: AxiosResponse<OfferAPI[]> = await axiosInstance.get('/offers/');
|
||||||
console.log(data)
|
console.log(data);
|
||||||
if (data.length > 0){
|
if (data.length > 0) {
|
||||||
console.log(data)
|
console.log(data);
|
||||||
const fetchedOffers: Offer[] = data.map(item => {
|
const fetchedOffers: Offer[] = data.map((item) => {
|
||||||
|
console.log(item);
|
||||||
console.log(item)
|
return {
|
||||||
return {
|
id: item.id,
|
||||||
id: item.id,
|
sender: item.user.first_name + ' ' + item.user.last_name,
|
||||||
sender: item.user.first_name + " " + item.user.last_name,
|
status: item.status,
|
||||||
status: item.status,
|
address: item.property.address,
|
||||||
address: item.property.address,
|
is_active: item.is_active,
|
||||||
is_active: item.is_active,
|
lastMessageTimestamp: item.updated_at,
|
||||||
lastMessageTimestamp: item.updated_at,
|
market_value: item.property.market_value,
|
||||||
market_value: item.property.market_value,
|
offer_value: '100000',
|
||||||
offer_value: '100000',
|
sender_id: item.user.id,
|
||||||
sender_id: item.user.id,
|
property_id: item.property.id,
|
||||||
property_id: item.property.id
|
};
|
||||||
|
});
|
||||||
}
|
console.log(fetchedOffers);
|
||||||
})
|
setOffers(fetchedOffers);
|
||||||
console.log(fetchedOffers)
|
|
||||||
setOffers(fetchedOffers);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {}
|
||||||
|
};
|
||||||
fetchOffers();
|
fetchOffers();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
type offerChoice = 'accept' | 'counter' | 'reject';
|
type offerChoice = 'accept' | 'counter' | 'reject';
|
||||||
|
|
||||||
const handleOffer = async (choice: offerChoice) => {
|
const handleOffer = async (choice: offerChoice) => {
|
||||||
console.log(choice)
|
console.log(choice);
|
||||||
}
|
};
|
||||||
|
|
||||||
const selectedOffer = offers.find(
|
const selectedOffer = offers.find((conv) => conv.id === selectedOfferId);
|
||||||
(conv) => conv.id === selectedOfferId
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<Container
|
||||||
<Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
maxWidth="lg"
|
||||||
<Grid container sx={{ height: '100%' }}>
|
sx={{ py: 4, height: '100vh', display: 'flex', flexDirection: 'column' }}
|
||||||
{/* Left Panel: Offer List */}
|
>
|
||||||
<Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
<Paper
|
||||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
elevation={3}
|
||||||
<Stack direction="row" sx={{width:'100%'}}>
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Grid container sx={{ height: '100%' }}>
|
||||||
|
{/* Left Panel: Offer List */}
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, md: 4 }}
|
||||||
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
|
||||||
|
<Stack direction="row" sx={{ width: '100%' }}>
|
||||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
Offers
|
Offers
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant='contained'
|
variant="contained"
|
||||||
color='primary'
|
color="primary"
|
||||||
sx={{ml:'auto'}}
|
sx={{ ml: 'auto' }}
|
||||||
onClick={() => setShowDialog(true)}
|
onClick={() => setShowDialog(true)}
|
||||||
>
|
>
|
||||||
Create Offer
|
Create Offer
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||||
{offers.length === 0 ? (
|
{offers.length === 0 ? (
|
||||||
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||||
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
|
<LocalOffer sx={{ fontSize: 40, mb: 1 }} />
|
||||||
<Typography>No offers submited yet.</Typography>
|
<Typography>No offers submited yet.</Typography>
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
offers
|
|
||||||
.sort((a, b) => new Date(b.lastMessageTimestamp).getTime() - new Date(a.lastMessageTimestamp).getTime())
|
|
||||||
.map((conv) => (
|
|
||||||
<ListItem
|
|
||||||
key={conv.id}
|
|
||||||
button
|
|
||||||
selected={selectedOfferId === conv.id}
|
|
||||||
onClick={() => setSelectedOfferId(conv.id)}
|
|
||||||
sx={{ py: 1.5, px: 2 }}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
|
||||||
{conv.sender}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" noWrap sx={{ flexGrow: 1, pr: 1 }}>
|
|
||||||
{conv.address}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.disabled">
|
|
||||||
{formatTimestamp(conv.lastMessageTimestamp)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Right Panel: Offer Detail */}
|
|
||||||
<Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{selectedOffer ? (
|
|
||||||
<>
|
|
||||||
{/* Offer Header */}
|
|
||||||
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Avatar sx={{ bgcolor: 'purple.200'}}>
|
|
||||||
{selectedOffer.sender.split(' ').map(n => n[0]).join('')}
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
|
||||||
{selectedOffer.sender}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Messages Area */}
|
|
||||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
|
||||||
{/* add the offer details here */}
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary>
|
|
||||||
Offer for {selectedOffer.address}
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Typography>
|
|
||||||
Offer Price: <strong>{selectedOffer.offer_value}</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography>
|
|
||||||
Waived Inspection: <strong>No</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography>
|
|
||||||
Closing date: <strong>90 days</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography>
|
|
||||||
Status: <strong>{selectedOffer.status}</strong>
|
|
||||||
</Typography>
|
|
||||||
</AccordionDetails>
|
|
||||||
<AccordionActions>
|
|
||||||
|
|
||||||
{selectedOffer.status === 'submitted' ? (
|
|
||||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async() => handleOffer('accept')}
|
|
||||||
endIcon={<SendIcon />}
|
|
||||||
sx={{ px: 3, py: 1.2 }}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async() => handleOffer('counter')}
|
|
||||||
endIcon={<SendIcon />}
|
|
||||||
sx={{ px: 3, py: 1.2 }}
|
|
||||||
>
|
|
||||||
Counter
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={ async() => handleOffer('reject')}
|
|
||||||
endIcon={<SendIcon />}
|
|
||||||
sx={{ px: 3, py: 1.2 }}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
): (
|
|
||||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'grey.200', display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async() => withdrawOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
|
||||||
endIcon={<DeleteForeverIcon />}
|
|
||||||
sx={{ px: 3, py: 1.2 }}
|
|
||||||
>
|
|
||||||
Withdraw Offer
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async() => submitOffer({offer_id: selectedOffer.id, sender_id: selectedOffer.sender_id, property_id: selectedOffer.property_id})}
|
|
||||||
endIcon={<SendIcon />}
|
|
||||||
sx={{ px: 3, py: 1.2 }}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</AccordionActions>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Message Input */}
|
|
||||||
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
|
||||||
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
|
|
||||||
<Typography variant="h6">Select an offer to view</Typography>
|
|
||||||
<Typography variant="body2">Click on an offer from the left panel to get started.</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
offers
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.lastMessageTimestamp).getTime() -
|
||||||
|
new Date(a.lastMessageTimestamp).getTime(),
|
||||||
|
)
|
||||||
|
.map((conv) => (
|
||||||
|
<ListItem
|
||||||
|
key={conv.id}
|
||||||
|
button
|
||||||
|
selected={selectedOfferId === conv.id}
|
||||||
|
onClick={() => setSelectedOfferId(conv.id)}
|
||||||
|
sx={{ py: 1.5, px: 2 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{conv.sender}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ flexGrow: 1, pr: 1 }}
|
||||||
|
>
|
||||||
|
{conv.address}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{formatTimestamp(conv.lastMessageTimestamp)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</List>
|
||||||
</Grid>
|
</Grid>
|
||||||
<CreateOfferDialog showDialog={showDialog} createOffer={createOffer} closeDialog={closeDialog} />
|
|
||||||
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Offers;
|
{/* Right Panel: Offer Detail */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{selectedOffer ? (
|
||||||
|
<>
|
||||||
|
{/* Offer Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ bgcolor: 'purple.200' }}>
|
||||||
|
{selectedOffer.sender
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{selectedOffer.sender}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||||
|
{/* add the offer details here */}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>Offer for {selectedOffer.address}</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography>
|
||||||
|
Offer Price: <strong>{selectedOffer.offer_value}</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
Waived Inspection: <strong>No</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
Closing date: <strong>90 days</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
Status: <strong>{selectedOffer.status}</strong>
|
||||||
|
</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
<AccordionActions>
|
||||||
|
{selectedOffer.status === 'submitted' ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={async () => handleOffer('accept')}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={async () => handleOffer('counter')}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Counter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={async () => handleOffer('reject')}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={async () =>
|
||||||
|
withdrawOffer({
|
||||||
|
offer_id: selectedOffer.id,
|
||||||
|
sender_id: selectedOffer.sender_id,
|
||||||
|
property_id: selectedOffer.property_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
endIcon={<DeleteForeverIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Withdraw Offer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={async () =>
|
||||||
|
submitOffer({
|
||||||
|
offer_id: selectedOffer.id,
|
||||||
|
sender_id: selectedOffer.sender_id,
|
||||||
|
property_id: selectedOffer.property_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</AccordionActions>
|
||||||
|
</Accordion>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 3,
|
||||||
|
color: 'grey.500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalOffer sx={{ fontSize: 80, mb: 2 }} />
|
||||||
|
<Typography variant="h6">Select an offer to view</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Click on an offer from the left panel to get started.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<CreateOfferDialog
|
||||||
|
showDialog={showDialog}
|
||||||
|
createOffer={createOffer}
|
||||||
|
closeDialog={closeDialog}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Offers;
|
||||||
|
|||||||
@@ -4,165 +4,238 @@ import { PropertiesAPI } from 'types';
|
|||||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||||
|
|
||||||
|
|
||||||
// Reusing the mockProperties from PropertyDetailPage for consistent data
|
// Reusing the mockProperties from PropertyDetailPage for consistent data
|
||||||
const mockProperties: PropertiesAPI[] = [
|
const mockProperties: PropertiesAPI[] = [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
owner: {
|
||||||
address: '123 Main St',
|
user: {
|
||||||
city: 'Anytown',
|
id: 1,
|
||||||
state: 'CA',
|
email: 'john.doe@example.com',
|
||||||
zip_code: '90210',
|
first_name: 'John',
|
||||||
market_value: '500000',
|
last_name: 'Doe',
|
||||||
loan_amount: '300000',
|
user_type: 'property_owner',
|
||||||
loan_term: 30,
|
is_active: true,
|
||||||
loan_start_date: '2020-05-01',
|
date_joined: '2023-01-15',
|
||||||
created_at: '2020-04-20',
|
tos_signed: true,
|
||||||
last_updated: '2023-10-10',
|
profile_created: true,
|
||||||
pictures: [
|
tier: 'basic',
|
||||||
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
|
},
|
||||||
'https://via.placeholder.com/600x400?text=Property+1+Living',
|
phone_number: '123-456-7890',
|
||||||
],
|
|
||||||
description: 'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
|
|
||||||
sq_ft: 1800,
|
|
||||||
features: ['Garage', 'Central AC', 'Hardwood Floors'],
|
|
||||||
num_bedrooms: 3,
|
|
||||||
num_bathrooms: 2,
|
|
||||||
latitude: 34.0522, // Example coordinates for Los Angeles
|
|
||||||
longitude: -118.2437,
|
|
||||||
},
|
},
|
||||||
{
|
address: '123 Main St',
|
||||||
id: 102,
|
city: 'Anytown',
|
||||||
owner: { user: { id: 1, email: 'john.doe@example.com', first_name: 'John', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '123-456-7890' },
|
state: 'CA',
|
||||||
address: '456 Oak Ave',
|
zip_code: '90210',
|
||||||
city: 'Anytown',
|
market_value: '500000',
|
||||||
state: 'CA',
|
loan_amount: '300000',
|
||||||
zip_code: '90210',
|
loan_term: 30,
|
||||||
market_value: '750000',
|
loan_start_date: '2020-05-01',
|
||||||
loan_amount: '500000',
|
created_at: '2020-04-20',
|
||||||
loan_term: 20,
|
last_updated: '2023-10-10',
|
||||||
loan_start_date: '2022-01-10',
|
pictures: [
|
||||||
created_at: '2021-12-01',
|
'https://via.placeholder.com/600x400?text=Property+1+Exterior',
|
||||||
last_updated: '2023-11-20',
|
'https://via.placeholder.com/600x400?text=Property+1+Living',
|
||||||
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
|
],
|
||||||
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
|
description:
|
||||||
sq_ft: 2500,
|
'A beautiful 3-bedroom, 2-bathroom house in a quiet neighborhood. Features a spacious backyard and modern kitchen.',
|
||||||
features: ['Pool', 'Fireplace', 'Large Yard'],
|
sq_ft: 1800,
|
||||||
num_bedrooms: 4,
|
features: ['Garage', 'Central AC', 'Hardwood Floors'],
|
||||||
num_bathrooms: 3,
|
num_bedrooms: 3,
|
||||||
latitude: 34.075, // Another example coordinate
|
num_bathrooms: 2,
|
||||||
longitude: -118.30,
|
latitude: 34.0522, // Example coordinates for Los Angeles
|
||||||
|
longitude: -118.2437,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
owner: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
user_type: 'property_owner',
|
||||||
|
is_active: true,
|
||||||
|
date_joined: '2023-01-15',
|
||||||
|
tos_signed: true,
|
||||||
|
profile_created: true,
|
||||||
|
tier: 'basic',
|
||||||
|
},
|
||||||
|
phone_number: '123-456-7890',
|
||||||
},
|
},
|
||||||
{
|
address: '456 Oak Ave',
|
||||||
id: 103,
|
city: 'Anytown',
|
||||||
owner: { user: { id: 99, email: 'another.owner@example.com', first_name: 'Jane', last_name: 'Doe', user_type: 'property_owner', is_active: true, date_joined: '2023-01-15', tos_signed: true, profile_created: true, tier: 'basic' }, phone_number: '987-654-3210' },
|
state: 'CA',
|
||||||
address: '789 Pine Lane',
|
zip_code: '90210',
|
||||||
city: 'Otherville',
|
market_value: '750000',
|
||||||
state: 'NY',
|
loan_amount: '500000',
|
||||||
zip_code: '10001',
|
loan_term: 20,
|
||||||
market_value: '1200000',
|
loan_start_date: '2022-01-10',
|
||||||
loan_amount: '800000',
|
created_at: '2021-12-01',
|
||||||
loan_term: 15,
|
last_updated: '2023-11-20',
|
||||||
loan_start_date: '2021-03-20',
|
pictures: ['https://via.placeholder.com/600x400?text=Property+2+Front'],
|
||||||
created_at: '2021-02-15',
|
description: 'Large family home with 4 bedrooms and a large pool. Perfect for entertaining.',
|
||||||
last_updated: '2024-01-05',
|
sq_ft: 2500,
|
||||||
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
|
features: ['Pool', 'Fireplace', 'Large Yard'],
|
||||||
description: 'Luxury apartment in the heart of the city with stunning views.',
|
num_bedrooms: 4,
|
||||||
sq_ft: 1200,
|
num_bathrooms: 3,
|
||||||
features: ['City View', 'Gym Access', 'Doorman'],
|
latitude: 34.075, // Another example coordinate
|
||||||
num_bedrooms: 2,
|
longitude: -118.3,
|
||||||
num_bathrooms: 2,
|
},
|
||||||
latitude: 40.7128, // NYC
|
{
|
||||||
longitude: -74.0060,
|
id: 103,
|
||||||
|
owner: {
|
||||||
|
user: {
|
||||||
|
id: 99,
|
||||||
|
email: 'another.owner@example.com',
|
||||||
|
first_name: 'Jane',
|
||||||
|
last_name: 'Doe',
|
||||||
|
user_type: 'property_owner',
|
||||||
|
is_active: true,
|
||||||
|
date_joined: '2023-01-15',
|
||||||
|
tos_signed: true,
|
||||||
|
profile_created: true,
|
||||||
|
tier: 'basic',
|
||||||
|
},
|
||||||
|
phone_number: '987-654-3210',
|
||||||
},
|
},
|
||||||
{
|
address: '789 Pine Lane',
|
||||||
id: 104,
|
city: 'Otherville',
|
||||||
owner: { user: { id: 100, email: 'test.user@example.com', first_name: 'Bob', last_name: 'Brown', user_type: 'property_owner', is_active: true, date_joined: '2024-01-01', tos_signed: true, profile_created: true, tier: 'premium' }, phone_number: '555-987-6543' },
|
state: 'NY',
|
||||||
address: '101 Elm Street',
|
zip_code: '10001',
|
||||||
city: 'Sampleton',
|
market_value: '1200000',
|
||||||
state: 'TX',
|
loan_amount: '800000',
|
||||||
zip_code: '75001',
|
loan_term: 15,
|
||||||
market_value: '350000',
|
loan_start_date: '2021-03-20',
|
||||||
loan_amount: '250000',
|
created_at: '2021-02-15',
|
||||||
loan_term: 30,
|
last_updated: '2024-01-05',
|
||||||
loan_start_date: '2023-07-01',
|
pictures: ['https://via.placeholder.com/600x400?text=NY+Property'],
|
||||||
created_at: '2023-06-20',
|
description: 'Luxury apartment in the heart of the city with stunning views.',
|
||||||
last_updated: '2024-06-15',
|
sq_ft: 1200,
|
||||||
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
|
features: ['City View', 'Gym Access', 'Doorman'],
|
||||||
description: 'Cozy starter home with a large yard, ideal for families.',
|
num_bedrooms: 2,
|
||||||
sq_ft: 1500,
|
num_bathrooms: 2,
|
||||||
features: ['Large Yard', 'New Roof'],
|
latitude: 40.7128, // NYC
|
||||||
num_bedrooms: 3,
|
longitude: -74.006,
|
||||||
num_bathrooms: 2,
|
},
|
||||||
latitude: 32.7767, // Dallas
|
{
|
||||||
longitude: -96.7970,
|
id: 104,
|
||||||
}
|
owner: {
|
||||||
|
user: {
|
||||||
|
id: 100,
|
||||||
|
email: 'test.user@example.com',
|
||||||
|
first_name: 'Bob',
|
||||||
|
last_name: 'Brown',
|
||||||
|
user_type: 'property_owner',
|
||||||
|
is_active: true,
|
||||||
|
date_joined: '2024-01-01',
|
||||||
|
tos_signed: true,
|
||||||
|
profile_created: true,
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
phone_number: '555-987-6543',
|
||||||
|
},
|
||||||
|
address: '101 Elm Street',
|
||||||
|
city: 'Sampleton',
|
||||||
|
state: 'TX',
|
||||||
|
zip_code: '75001',
|
||||||
|
market_value: '350000',
|
||||||
|
loan_amount: '250000',
|
||||||
|
loan_term: 30,
|
||||||
|
loan_start_date: '2023-07-01',
|
||||||
|
created_at: '2023-06-20',
|
||||||
|
last_updated: '2024-06-15',
|
||||||
|
pictures: ['https://via.placeholder.com/600x400?text=TX+House'],
|
||||||
|
description: 'Cozy starter home with a large yard, ideal for families.',
|
||||||
|
sq_ft: 1500,
|
||||||
|
features: ['Large Yard', 'New Roof'],
|
||||||
|
num_bedrooms: 3,
|
||||||
|
num_bathrooms: 2,
|
||||||
|
latitude: 32.7767, // Dallas
|
||||||
|
longitude: -96.797,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const Property: React.FC = () => {
|
const Property: React.FC = () => {
|
||||||
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||||
const [initialLoad, setInitialLoad] = useState(true);
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
|
|
||||||
const filterProperties = (filters: any) => {
|
const filterProperties = (filters: any) => {
|
||||||
const filtered = mockProperties.filter(property => {
|
const filtered = mockProperties.filter((property) => {
|
||||||
const addressMatch = filters.address ? property.address.toLowerCase().includes(filters.address.toLowerCase()) : true;
|
const addressMatch = filters.address
|
||||||
const cityMatch = filters.city ? property.city.toLowerCase().includes(filters.city.toLowerCase()) : true;
|
? property.address.toLowerCase().includes(filters.address.toLowerCase())
|
||||||
const stateMatch = filters.state ? property.state.toLowerCase() === filters.state.toLowerCase() : true;
|
: true;
|
||||||
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
const cityMatch = filters.city
|
||||||
|
? property.city.toLowerCase().includes(filters.city.toLowerCase())
|
||||||
|
: true;
|
||||||
|
const stateMatch = filters.state
|
||||||
|
? property.state.toLowerCase() === filters.state.toLowerCase()
|
||||||
|
: true;
|
||||||
|
const zipCodeMatch = filters.zipCode ? property.zip_code.includes(filters.zipCode) : true;
|
||||||
|
|
||||||
const sqFtMatch = (filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
const sqFtMatch =
|
||||||
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
(filters.minSqFt === '' || property.sq_ft >= filters.minSqFt) &&
|
||||||
const bedroomsMatch = (filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
(filters.maxSqFt === '' || property.sq_ft <= filters.maxSqFt);
|
||||||
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
const bedroomsMatch =
|
||||||
const bathroomsMatch = (filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
(filters.minBedrooms === '' || property.num_bedrooms >= filters.minBedrooms) &&
|
||||||
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
(filters.maxBedrooms === '' || property.num_bedrooms <= filters.maxBedrooms);
|
||||||
|
const bathroomsMatch =
|
||||||
|
(filters.minBathrooms === '' || property.num_bathrooms >= filters.minBathrooms) &&
|
||||||
|
(filters.maxBathrooms === '' || property.num_bathrooms <= filters.maxBathrooms);
|
||||||
|
|
||||||
return addressMatch && cityMatch && stateMatch && zipCodeMatch &&
|
return (
|
||||||
sqFtMatch && bedroomsMatch && bathroomsMatch;
|
addressMatch &&
|
||||||
});
|
cityMatch &&
|
||||||
setSearchResults(filtered);
|
stateMatch &&
|
||||||
setInitialLoad(false);
|
zipCodeMatch &&
|
||||||
};
|
sqFtMatch &&
|
||||||
|
bedroomsMatch &&
|
||||||
|
bathroomsMatch
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setSearchResults(filtered);
|
||||||
|
setInitialLoad(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = (filters: any) => {
|
const handleSearch = (filters: any) => {
|
||||||
filterProperties(filters);
|
filterProperties(filters);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setSearchResults([]); // Clear results on clear
|
setSearchResults([]); // Clear results on clear
|
||||||
setInitialLoad(true); // Reset to initial state
|
setInitialLoad(true); // Reset to initial state
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
Property Search
|
Property Search
|
||||||
</Typography>
|
</Typography>
|
||||||
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||||
|
|
||||||
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
<Typography variant="h5" sx={{ mt: 4, mb: 2 }}>
|
||||||
{initialLoad ? 'Enter search criteria to find properties.' : `Search Results (${searchResults.length} found)`}
|
{initialLoad
|
||||||
</Typography>
|
? 'Enter search criteria to find properties.'
|
||||||
<Grid container spacing={2}>
|
: `Search Results (${searchResults.length} found)`}
|
||||||
{searchResults.length === 0 && !initialLoad ? (
|
</Typography>
|
||||||
<Grid item xs={12}>
|
<Grid container spacing={2}>
|
||||||
<Alert severity="info">No properties found matching your criteria.</Alert>
|
{searchResults.length === 0 && !initialLoad ? (
|
||||||
</Grid>
|
<Grid size={{ xs: 12 }}>
|
||||||
) : (
|
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||||
searchResults.map(property => (
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={4} key={property.id}>
|
) : (
|
||||||
<PropertyListItem
|
searchResults.map((property) => (
|
||||||
property={property}
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={property.id}>
|
||||||
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
<PropertyListItem
|
||||||
/>
|
property={property}
|
||||||
</Grid>
|
onViewDetails={() => console.log('Navigate to details for:', property.id)} // Handled internally by navigate
|
||||||
))
|
/>
|
||||||
)}
|
</Grid>
|
||||||
</Grid>
|
))
|
||||||
</Container>
|
)}
|
||||||
);
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Property;
|
export default Property;
|
||||||
@@ -185,7 +258,6 @@ export default Property;
|
|||||||
// import HouseIcon from '@mui/icons-material/House';
|
// import HouseIcon from '@mui/icons-material/House';
|
||||||
// import { formatTimestamp } from 'utils';
|
// import { formatTimestamp } from 'utils';
|
||||||
|
|
||||||
|
|
||||||
// const Property = (): ReactElement => {
|
// const Property = (): ReactElement => {
|
||||||
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
// const [properties, setProperties] = useState<PropertiesAPI[]>([]);
|
||||||
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
|
// const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null)
|
||||||
@@ -202,7 +274,6 @@ export default Property;
|
|||||||
// setSelectedPropertyId(data[0].id)
|
// setSelectedPropertyId(data[0].id)
|
||||||
|
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
// }catch(error){
|
// }catch(error){
|
||||||
// console.log(error)
|
// console.log(error)
|
||||||
@@ -214,7 +285,7 @@ export default Property;
|
|||||||
// const selectedProperty = properties.find(
|
// const selectedProperty = properties.find(
|
||||||
// (property) => property.id === selectedPropertyId
|
// (property) => property.id === selectedPropertyId
|
||||||
// );
|
// );
|
||||||
|
|
||||||
// return(
|
// return(
|
||||||
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
// <Container maxWidth="lg" sx={{ py: 4, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
// <Paper elevation={3} sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
@@ -223,8 +294,7 @@ export default Property;
|
|||||||
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
// <Grid item xs={12} md={4} sx={{ borderRight: { md: '1px solid', borderColor: 'grey.200' }, display: 'flex', flexDirection: 'column' }}>
|
||||||
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
// <Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display:'flex' }}>
|
||||||
// <Stack direction="row" sx={{width:'100%'}}>
|
// <Stack direction="row" sx={{width:'100%'}}>
|
||||||
|
|
||||||
|
|
||||||
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
// <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
// Properties
|
// Properties
|
||||||
// </Typography>
|
// </Typography>
|
||||||
@@ -237,7 +307,7 @@ export default Property;
|
|||||||
// <Typography>No properties yet. Go to profile to add one.</Typography>
|
// <Typography>No properties yet. Go to profile to add one.</Typography>
|
||||||
// </Box>
|
// </Box>
|
||||||
// ) : (
|
// ) : (
|
||||||
|
|
||||||
// properties
|
// properties
|
||||||
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
// .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
// .map((conv) => (
|
// .map((conv) => (
|
||||||
@@ -270,7 +340,7 @@ export default Property;
|
|||||||
// )}
|
// )}
|
||||||
// </List>
|
// </List>
|
||||||
// </Grid>
|
// </Grid>
|
||||||
|
|
||||||
// {/* Right Panel: Conversation Detail */}
|
// {/* Right Panel: Conversation Detail */}
|
||||||
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
// <Grid item xs={12} md={8} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
// {selectedProperty ? (
|
// {selectedProperty ? (
|
||||||
@@ -284,7 +354,7 @@ export default Property;
|
|||||||
// {selectedProperty.address}
|
// {selectedProperty.address}
|
||||||
// </Typography>
|
// </Typography>
|
||||||
// </Box>
|
// </Box>
|
||||||
|
|
||||||
// {/* Messages Area */}
|
// {/* Messages Area */}
|
||||||
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
// <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||||
// <Grid
|
// <Grid
|
||||||
@@ -302,12 +372,12 @@ export default Property;
|
|||||||
// }}
|
// }}
|
||||||
// >
|
// >
|
||||||
// <Grid xs={12} md={8}>
|
// <Grid xs={12} md={8}>
|
||||||
|
|
||||||
// <PropertyDetailsCard />
|
// <PropertyDetailsCard />
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={4}>
|
// <Grid xs={12} md={4}>
|
||||||
// <HomePriceEstimate />
|
// <HomePriceEstimate />
|
||||||
|
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={8}>
|
// <Grid xs={12} md={8}>
|
||||||
// <PhotoGalleryCard />
|
// <PhotoGalleryCard />
|
||||||
@@ -315,25 +385,24 @@ export default Property;
|
|||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={4}>
|
// <Grid xs={12} md={4}>
|
||||||
// <MarketStatistics />
|
// <MarketStatistics />
|
||||||
|
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={8}>
|
// <Grid xs={12} md={8}>
|
||||||
// <PropertyValueGraphCard />
|
// <PropertyValueGraphCard />
|
||||||
|
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={4}>
|
// <Grid xs={12} md={4}>
|
||||||
// <LoanDetailsCard />
|
// <LoanDetailsCard />
|
||||||
|
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// <Grid xs={12} md={4}>
|
// <Grid xs={12} md={4}>
|
||||||
// <PropertyListingCard />
|
// <PropertyListingCard />
|
||||||
|
|
||||||
// </Grid>
|
// </Grid>
|
||||||
// </Grid>
|
// </Grid>
|
||||||
|
|
||||||
// </Box>
|
// </Box>
|
||||||
|
|
||||||
|
|
||||||
// </>
|
// </>
|
||||||
// ) : (
|
// ) : (
|
||||||
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
// <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 3, color: 'grey.500' }}>
|
||||||
@@ -364,12 +433,12 @@ export default Property;
|
|||||||
// // }}
|
// // }}
|
||||||
// // >
|
// // >
|
||||||
// // <Grid xs={12} md={8}>
|
// // <Grid xs={12} md={8}>
|
||||||
|
|
||||||
// // <PropertyDetailsCard />
|
// // <PropertyDetailsCard />
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={4}>
|
// // <Grid xs={12} md={4}>
|
||||||
// // <HomePriceEstimate />
|
// // <HomePriceEstimate />
|
||||||
|
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={8}>
|
// // <Grid xs={12} md={8}>
|
||||||
// // <PhotoGalleryCard />
|
// // <PhotoGalleryCard />
|
||||||
@@ -377,22 +446,22 @@ export default Property;
|
|||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={4}>
|
// // <Grid xs={12} md={4}>
|
||||||
// // <MarketStatistics />
|
// // <MarketStatistics />
|
||||||
|
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={8}>
|
// // <Grid xs={12} md={8}>
|
||||||
// // <PropertyValueGraphCard />
|
// // <PropertyValueGraphCard />
|
||||||
|
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={4}>
|
// // <Grid xs={12} md={4}>
|
||||||
// // <LoanDetailsCard />
|
// // <LoanDetailsCard />
|
||||||
|
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // <Grid xs={12} md={4}>
|
// // <Grid xs={12} md={4}>
|
||||||
// // <PropertyListingCard />
|
// // <PropertyListingCard />
|
||||||
|
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // </Grid>
|
// // </Grid>
|
||||||
// // )
|
// // )
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// export default Property;
|
// export default Property;
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Container, Typography, CircularProgress, Grid, Alert, Divider } from '@mui/material';
|
import {
|
||||||
import { PropertiesAPI, UserAPI, WalkScoreAPI } from 'types';
|
Container,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
private_excludeVariablesFromRoot,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
||||||
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
||||||
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
||||||
@@ -9,16 +20,18 @@ import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseC
|
|||||||
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
||||||
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
||||||
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
||||||
import { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { axiosInstance } from '../../axiosApi';
|
import { axiosInstance } from '../../axiosApi';
|
||||||
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||||
import { AccountContext } from 'contexts/AccountContext';
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
|
||||||
|
|
||||||
const PropertyDetailPage: React.FC = () => {
|
const PropertyDetailPage: React.FC = () => {
|
||||||
// In a real app, you'd get propertyId from URL params or a global state
|
// In a real app, you'd get propertyId from URL params or a global state
|
||||||
const { account, accountLoading } = useContext(AccountContext);
|
const { account, accountLoading } = useContext(AccountContext);
|
||||||
const { propertyId } = useParams<{ propertyId: string }>();
|
const { propertyId } = useParams<{ propertyId: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const isSearch = searchParams.get('search') === '1';
|
const isSearch = searchParams.get('search') === '1';
|
||||||
|
|
||||||
@@ -26,175 +39,282 @@ const PropertyDetailPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [savedProperty, setSavedProperty] = useState<SavedPropertiesAPI | null>(null);
|
||||||
|
|
||||||
if (accountLoading) {
|
useEffect(() => {
|
||||||
return <>Page is loading</>;
|
// Simulate API call
|
||||||
} else if (!accountLoading && !account) {
|
const getProperty = async () => {
|
||||||
return <>There was an error</>;
|
|
||||||
} else {
|
|
||||||
useEffect(() => {
|
|
||||||
// Simulate API call
|
|
||||||
const getProperty = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const url = isSearch
|
|
||||||
? `/properties/${propertyId}/?search=1`
|
|
||||||
: `/properties/${propertyId}/`;
|
|
||||||
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
|
||||||
if (isSearch) {
|
|
||||||
// kick the view count
|
|
||||||
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
|
|
||||||
}
|
|
||||||
if (data !== undefined) {
|
|
||||||
setProperty(data);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Property not found.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
getProperty();
|
|
||||||
}, [propertyId]);
|
|
||||||
|
|
||||||
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
|
||||||
// In a real app, this would be an API call to update the property
|
|
||||||
console.log('Saving property:', updatedProperty);
|
|
||||||
setProperty(updatedProperty);
|
|
||||||
setMessage({ type: 'success', text: 'Property details updated successfully!' });
|
|
||||||
setTimeout(() => setMessage(null), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStatusChange = () => {
|
|
||||||
if (property) {
|
|
||||||
setProperty((property) => ({ ...property, status: 'active' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSavedPropertySave = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.post(`/saved-properties/`, {
|
setLoading(true);
|
||||||
property: property.id,
|
setError(null);
|
||||||
user: account.id,
|
const url = isSearch ? `/properties/${propertyId}/?search=1` : `/properties/${propertyId}/`;
|
||||||
});
|
const { data }: AxiosResponse<PropertiesAPI> = await axiosInstance.get(url);
|
||||||
console.log(response);
|
if (isSearch) {
|
||||||
} catch (error) {
|
// kick the view count
|
||||||
console.log(error);
|
await axiosInstance.post(`/properties/${propertyId}/increment_view_count/?search=1`);
|
||||||
|
}
|
||||||
|
if (data !== undefined) {
|
||||||
|
setProperty(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Property not found.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const getSavedProperties = async () => {
|
||||||
const handleDeleteProperty = (propertyId: number) => {
|
if (account) {
|
||||||
console.log('handle delete. IMPLEMENT ME');
|
// only fetch if logged in
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<SavedPropertiesAPI[]> =
|
||||||
|
await axiosInstance.get('/saved-properties/');
|
||||||
|
setSavedProperty(data.find((item) => item.property.toString() === propertyId));
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
getProperty();
|
||||||
|
getSavedProperties();
|
||||||
|
}, [propertyId, account, isSearch]);
|
||||||
|
|
||||||
const handleOfferSubmit = (offerAmount: number) => {
|
const handleSaveProperty = (updatedProperty: PropertiesAPI) => {
|
||||||
console.log(`New offer submitted for property ID ${propertyId}: $${offerAmount}`);
|
// In a real app, this would be an API call to update the property
|
||||||
// Here you would send the offer to your backend API
|
console.log('Saving property:', updatedProperty);
|
||||||
};
|
setProperty(updatedProperty);
|
||||||
|
setMessage({ type: 'success', text: 'Property details updated successfully!' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
const onStatusChange = async (value: string) => {
|
||||||
return (
|
if (property) {
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
try {
|
||||||
<CircularProgress />
|
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, {
|
||||||
<Typography>Loading property details...</Typography>
|
property_status: value,
|
||||||
</Container>
|
});
|
||||||
);
|
setProperty((prev) => (prev ? { ...prev, property_status: value } : null));
|
||||||
}
|
setMessage({ type: 'success', text: `Your listing is now ${value}` });
|
||||||
|
} catch (error) {
|
||||||
if (error) {
|
setMessage({
|
||||||
return (
|
type: 'error',
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
text: 'There was an error saving your selection. Please try again',
|
||||||
<Alert severity="error">{error}</Alert>
|
});
|
||||||
</Container>
|
setTimeout(() => setMessage(null), 3000);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!property) {
|
|
||||||
return (
|
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
|
||||||
<Alert severity="info">No property data available.</Alert>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if the current user is the owner of this property
|
|
||||||
const isOwnerOfProperty = account.id === property.owner.user.id;
|
|
||||||
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
|
||||||
let listed_price: string;
|
|
||||||
if (property.listed_price === undefined || property.listed_price === null) {
|
|
||||||
listed_price = 'No Price';
|
|
||||||
} else {
|
} else {
|
||||||
listed_price = parseFloat(property.listed_price).toString();
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'There was an error saving your selection. Please refresh the page and try again',
|
||||||
|
});
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
console.log(property);
|
const onSavedPropertySave = async () => {
|
||||||
|
if (property && account) {
|
||||||
|
try {
|
||||||
|
if (savedProperty) {
|
||||||
|
await axiosInstance.delete<SavedPropertiesAPI>(`/saved-properties/${savedProperty.id}/`);
|
||||||
|
setSavedProperty(null);
|
||||||
|
setProperty((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
saves: prev.saves - 1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const { data } = await axiosInstance.post<SavedPropertiesAPI>(`/saved-properties/`, {
|
||||||
|
property: property.id,
|
||||||
|
user: account.id,
|
||||||
|
});
|
||||||
|
setSavedProperty(data);
|
||||||
|
setProperty((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
saves: prev.saves + 1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'There was an error saving your selection. Please try again.',
|
||||||
|
});
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProperty = (propertyId: number) => {
|
||||||
|
console.log('handle delete. IMPLEMENT ME', propertyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (property && property.owner && property.owner.user) {
|
||||||
|
navigate(`/messages?recipient=${property.owner.user.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOfferSubmit = async (
|
||||||
|
offerAmount: number,
|
||||||
|
closing_days: number,
|
||||||
|
contingencies: string,
|
||||||
|
): Promise<{ status: number; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/documents/upload/', {
|
||||||
|
document_type: 'offer',
|
||||||
|
property: propertyId,
|
||||||
|
offer_price: offerAmount,
|
||||||
|
closing_days: closing_days,
|
||||||
|
contingencies: contingencies,
|
||||||
|
});
|
||||||
|
return { status: response.status };
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
return {
|
||||||
|
status: error.response.status,
|
||||||
|
message: error.response.data?.detail || 'Failed to submit offer.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
message: 'An unexpected error occurred.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || accountLoading) {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||||
{message && (
|
<CircularProgress />
|
||||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
<Typography>Loading property details...</Typography>
|
||||||
{message.text}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
{/* Main Property Details */}
|
|
||||||
<Grid item xs={12} md={8}>
|
|
||||||
<PropertyDetailCard
|
|
||||||
property={property}
|
|
||||||
isPublicPage={true}
|
|
||||||
onSave={handleSaveProperty}
|
|
||||||
isOwnerView={isOwnerOfProperty}
|
|
||||||
onDelete={handleDeleteProperty}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Status, Cost, Offers */}
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<PropertyStatusCard
|
|
||||||
property={property}
|
|
||||||
isOwner={isOwnerOfProperty}
|
|
||||||
onStatusChange={onStatusChange}
|
|
||||||
onSavedPropertySave={onSavedPropertySave}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<OfferSubmissionCard
|
|
||||||
onOfferSubmit={handleOfferSubmit}
|
|
||||||
listingStatus={property.property_status}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<OpenHouseCard openHouses={property.open_houses} />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Additional Information */}
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<WalkScoreCard walkScore={property.walk_score} />
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
{property.schools && <SchoolCard schools={property.schools} />}
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Alert severity="info">No property data available.</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the current user is the owner of this property
|
||||||
|
const isOwnerOfProperty = account ? account.id === property.owner.user.id : false;
|
||||||
|
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||||
|
|
||||||
|
const sellerDisclosureExists = property.documents
|
||||||
|
? property.documents.some(
|
||||||
|
(doc) => doc.document_type === 'seller_disclosure' && doc.sub_document,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const disclosureDocument = property.documents?.find(
|
||||||
|
(doc) => doc.document_type === 'seller_disclosure',
|
||||||
|
);
|
||||||
|
const sellerDisclosureData = disclosureDocument?.sub_document as any; // Using 'any' to avoid type conflicts for now.
|
||||||
|
|
||||||
|
const existingOffer = property.documents
|
||||||
|
? property.documents.find((doc) => doc.document_type === 'offer_letter')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// TODO fix this
|
||||||
|
console.log(property);
|
||||||
|
console.log(property.documents);
|
||||||
|
console.log(existingOffer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Main Property Details */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
<PropertyDetailCard
|
||||||
|
property={property}
|
||||||
|
isPublicPage={true}
|
||||||
|
onSave={handleSaveProperty}
|
||||||
|
isOwnerView={isOwnerOfProperty}
|
||||||
|
onDelete={() => handleDeleteProperty(property.id)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<PropertyStatusCard
|
||||||
|
property={property}
|
||||||
|
isOwner={isOwnerOfProperty}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onSavedPropertySave={onSavedPropertySave}
|
||||||
|
savedProperty={savedProperty}
|
||||||
|
sellerDisclosureExists={sellerDisclosureExists}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||||
|
</Grid>
|
||||||
|
{!isOwnerOfProperty && (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<OfferSubmissionCard
|
||||||
|
onOfferSubmit={handleOfferSubmit}
|
||||||
|
listingStatus={property.property_status}
|
||||||
|
listingPrice={priceForAnalysis}
|
||||||
|
existingOffer={existingOffer ? { document_id: existingOffer.id } : undefined}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<SellerInformationCard
|
||||||
|
sellerDisclosureExists={sellerDisclosureExists}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
disclosureData={sellerDisclosureData}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<OpenHouseCard openHouses={property.open_houses} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Additional Information */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<WalkScoreCard walkScore={property.walk_score} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
{property.schools && <SchoolCard schools={property.schools} />}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PropertyDetailPage;
|
export default PropertyDetailPage;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Container, Typography, Box, Grid, Alert } from '@mui/material';
|
|||||||
import { PropertiesAPI } from 'types';
|
import { PropertiesAPI } from 'types';
|
||||||
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||||
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import MapSerachComponent from 'components/base/MapSearchComponent';
|
import MapSerachComponent from 'components/base/MapSearchComponent';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { axiosInstance } from '../../axiosApi';
|
import { axiosInstance } from '../../axiosApi';
|
||||||
@@ -125,7 +125,7 @@ const PropertySearchPage: React.FC = () => {
|
|||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Property List Section */}
|
{/* Property List Section */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
||||||
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
||||||
@@ -149,7 +149,7 @@ const PropertySearchPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Map Section */}
|
{/* Map Section */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<MapSerachComponent
|
<MapSerachComponent
|
||||||
center={mapState.center}
|
center={mapState.center}
|
||||||
zoom={mapState.zoom}
|
zoom={mapState.zoom}
|
||||||
|
|||||||
162
ditch-the-agent/src/pages/Property/PublicPropertyDetail.tsx
Normal file
162
ditch-the-agent/src/pages/Property/PublicPropertyDetail.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
|
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||||
|
import PropertyDetailCard from 'components/sections/dashboard/Home/Property/PropertyDetailCard';
|
||||||
|
import SaleTaxHistoryCard from 'components/sections/dashboard/Home/Property/SaleTaxHistoryCard';
|
||||||
|
import WalkScoreCard from 'components/sections/dashboard/Home/Property/WalkScoreCard';
|
||||||
|
import OpenHouseCard from 'components/sections/dashboard/Home/Profile/OpenHouseCard';
|
||||||
|
import PropertyStatusCard from 'components/sections/dashboard/Home/Property/PropertyStatusCard';
|
||||||
|
import EstimatedMonthlyCostCard from 'components/sections/dashboard/Home/Profile/EstimatedMonthlyCostCard';
|
||||||
|
import OfferSubmissionCard from 'components/sections/dashboard/Home/Profile/OfferSubmissionCard';
|
||||||
|
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||||
|
import { cleanAxiosInstance } from '../../axiosApi';
|
||||||
|
|
||||||
|
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
|
||||||
|
import LogInNotificationCard from 'components/sections/dashboard/Home/Property/LogInNotificationCard';
|
||||||
|
|
||||||
|
const PublicPropertyDetail: React.FC = () => {
|
||||||
|
// In a real app, you'd get propertyId from URL params or a global state
|
||||||
|
|
||||||
|
const { propertyId } = useParams<{ propertyId: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const [property, setProperty] = useState<PropertiesAPI | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulate API call
|
||||||
|
const getProperty = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const url = `/public/${propertyId}/`;
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI> = await cleanAxiosInstance.get(url);
|
||||||
|
|
||||||
|
if (data !== undefined) {
|
||||||
|
setProperty(data);
|
||||||
|
}
|
||||||
|
await cleanAxiosInstance.post(`/public/${propertyId}/increment_view_count/`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setError('Property not found.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getProperty();
|
||||||
|
}, [propertyId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4, textAlign: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography>Loading property details...</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Alert severity="info">No property data available.</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the current user is the owner of this property
|
||||||
|
const isOwnerOfProperty = false;
|
||||||
|
const priceForAnalysis = property.listed_price ? property.listed_price : property.market_value;
|
||||||
|
|
||||||
|
console.log('here');
|
||||||
|
console.log(property);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{message && (
|
||||||
|
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Main Property Details */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
<PropertyDetailCard
|
||||||
|
property={property}
|
||||||
|
isPublicPage={true}
|
||||||
|
onSave={null}
|
||||||
|
isOwnerView={isOwnerOfProperty}
|
||||||
|
onDelete={() => null}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Status, Cost, Offers */}
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<PropertyStatusCard
|
||||||
|
property={property}
|
||||||
|
isOwner={isOwnerOfProperty}
|
||||||
|
onStatusChange={null}
|
||||||
|
onSavedPropertySave={null}
|
||||||
|
savedProperty={null}
|
||||||
|
sellerDisclosureExists={null}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<LogInNotificationCard />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<OpenHouseCard openHouses={property.open_houses} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Additional Information */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<SaleTaxHistoryCard saleHistory={property.sale_info} taxInfo={property.tax_info} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<WalkScoreCard walkScore={property.walk_score} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
{property.schools && <SchoolCard schools={property.schools} />}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicPropertyDetail;
|
||||||
180
ditch-the-agent/src/pages/Property/PublicPropertySearch.tsx
Normal file
180
ditch-the-agent/src/pages/Property/PublicPropertySearch.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Link,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PropertiesAPI } from 'types';
|
||||||
|
import PropertySearchFilters from 'components/sections/dashboard/Home/Property/PropertySearchFilters';
|
||||||
|
import PropertyListItem from 'components/sections/dashboard/Home/Property/PropertyListItem';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import MapSerachComponent from 'components/base/MapSearchComponent';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { cleanAxiosInstance } from '../../axiosApi';
|
||||||
|
import { useMapsLibrary } from '@vis.gl/react-google-maps';
|
||||||
|
|
||||||
|
const PublicPropertySearchPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchResults, setSearchResults] = useState<PropertiesAPI[]>([]);
|
||||||
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
|
const [selectedPropertyId, setSelectedPropertyId] = useState<number | null>(null);
|
||||||
|
const [mapState, setMapState] = useState({
|
||||||
|
center: { lat: 39.8283, lng: -98.5795 }, // Center of the US
|
||||||
|
zoom: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProperties = async () => {
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await cleanAxiosInstance.get(`/public/`);
|
||||||
|
console.log(data);
|
||||||
|
data.map((item) => {
|
||||||
|
console.log(item);
|
||||||
|
});
|
||||||
|
if (data !== undefined) {
|
||||||
|
setSearchResults(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setInitialLoad(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProperties();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filterProperties = async (filters: any) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const key in filters) {
|
||||||
|
if (filters.hasOwnProperty(key)) {
|
||||||
|
const value = filters[key];
|
||||||
|
|
||||||
|
// Exclude attributes that don't have values (null, undefined, empty string)
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
searchParams.append(key, String(value)); // Ensure value is a string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
console.log(queryString);
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<PropertiesAPI[]> = await cleanAxiosInstance.get(
|
||||||
|
`/public/?${queryString}`,
|
||||||
|
);
|
||||||
|
console.log(data);
|
||||||
|
setSearchResults(data);
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setInitialLoad(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async (filters: any) => {
|
||||||
|
console.log(filters);
|
||||||
|
await filterProperties(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchResults([]); // Clear results on clear
|
||||||
|
setInitialLoad(true); // Reset to initial state
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapBoundsChange = ({ center, zoom, bounds }: any) => {
|
||||||
|
setMapState({ center, zoom });
|
||||||
|
// Optional: you could filter search results based on the map bounds
|
||||||
|
};
|
||||||
|
const handleMapMarkerClick = (propertyId: number) => {
|
||||||
|
navigate(`/properties/${propertyId}/&search=1`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBoxDrawn = (bounds: any) => {
|
||||||
|
const filtered = mockProperties.filter((p) => {
|
||||||
|
if (!p.latitude || !p.longitude) return false;
|
||||||
|
return (
|
||||||
|
p.latitude <= bounds.ne.lat &&
|
||||||
|
p.latitude >= bounds.sw.lat &&
|
||||||
|
p.longitude <= bounds.ne.lng &&
|
||||||
|
p.longitude >= bounds.sw.lng
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setSearchResults(filtered);
|
||||||
|
setInitialLoad(false);
|
||||||
|
// Optional: Adjust map zoom to fit the new search results
|
||||||
|
// You'd need to calculate the new center and zoom based on the filtered results.
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkerClick = (propertyId: number) => {
|
||||||
|
navigate(`/${propertyId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkerHover = (property: PropertiesAPI) => {
|
||||||
|
setSelectedPropertyId(property.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkerUnhover = () => {
|
||||||
|
setSelectedPropertyId(null);
|
||||||
|
};
|
||||||
|
console.log('EHRER i samsdaf;l');
|
||||||
|
|
||||||
|
console.log(searchResults);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'background.paper' }}>
|
||||||
|
Property Search & Map
|
||||||
|
</Typography>
|
||||||
|
<PropertySearchFilters onSearch={handleSearch} onClear={handleClearSearch} />
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Property List Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 2, color: 'background.paper' }}>
|
||||||
|
{initialLoad ? 'All Properties' : `Search Results (${searchResults.length} found)`}
|
||||||
|
</Typography>
|
||||||
|
{searchResults.length === 0 ? (
|
||||||
|
<Alert severity="info">No properties found matching your criteria.</Alert>
|
||||||
|
) : (
|
||||||
|
searchResults.map((property) => (
|
||||||
|
<PropertyListItem
|
||||||
|
key={property.id}
|
||||||
|
property={property}
|
||||||
|
isPublic={true}
|
||||||
|
onHover={handleMarkerHover}
|
||||||
|
onUnhover={handleMarkerUnhover}
|
||||||
|
// The click logic will now be handled by the marker
|
||||||
|
// We just highlight the selected marker
|
||||||
|
isSelected={selectedPropertyId === property.id}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
{/* Map Section */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<MapSerachComponent
|
||||||
|
center={mapState.center}
|
||||||
|
zoom={mapState.zoom}
|
||||||
|
properties={searchResults}
|
||||||
|
selectedPropertyId={selectedPropertyId}
|
||||||
|
onBoundsChanged={handleMapBoundsChange}
|
||||||
|
onBoxDrawn={handleBoxDrawn}
|
||||||
|
onMarkerClick={handleMapMarkerClick} // Pass the navigation handler
|
||||||
|
onMarkerHover={handleMarkerHover}
|
||||||
|
onMarkerUnhover={handleMarkerUnhover}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicPropertySearchPage;
|
||||||
84
ditch-the-agent/src/pages/Support/FAQ.tsx
Normal file
84
ditch-the-agent/src/pages/Support/FAQ.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { axiosInstance } from '../../axiosApi';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FaqApi } from 'types';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
|
import PageLoader from 'components/loading/PageLoader';
|
||||||
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
|
|
||||||
|
const FAQPage: React.FC = () => {
|
||||||
|
const [faqs, setFaqs] = useState<FaqApi[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFaqs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data }: AxiosResponse<FaqApi[]> = await axiosInstance.get(`/support/faq/`);
|
||||||
|
// Sort FAQs by order
|
||||||
|
const sortedFaqs = data.sort((a, b) => a.order - b.order);
|
||||||
|
setFaqs(sortedFaqs);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchFaqs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <PageLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
Failed to load FAQs. Please try again later.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md">
|
||||||
|
<Paper sx={{ my: 4, p: 3 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Frequently Asked Questions
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" paragraph>
|
||||||
|
Most questions can be answered by looking through the frequently asked questions below. If
|
||||||
|
you still have issues, you can <Link to="/support/manager">submit an issue</Link>.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={3} sx={{ p: 2 }}>
|
||||||
|
{faqs.map((faq) => (
|
||||||
|
<Accordion key={faq.order}>
|
||||||
|
<AccordionSummary expandIcon={<IconifyIcon icon="mdi:chevron-down" />}>
|
||||||
|
<Typography variant="h6">{faq.question}</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography>{faq.answer}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FAQPage;
|
||||||
418
ditch-the-agent/src/pages/Support/SupportManager.tsx
Normal file
418
ditch-the-agent/src/pages/Support/SupportManager.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import React, { useState, useEffect, useContext, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
import { axiosInstance } from '../../axiosApi';
|
||||||
|
import PageLoader from 'components/loading/PageLoader';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Avatar,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { SupportCaseApi, SupportMessageApi } from 'types';
|
||||||
|
import { AccountContext } from 'contexts/AccountContext';
|
||||||
|
import { formatTimestamp } from 'utils';
|
||||||
|
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||||
|
import QuestionMarkOutlined from '@mui/icons-material/QuestionMarkOutlined';
|
||||||
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import CreateSupportCaseDialogContent from 'components/sections/dashboard/Home/Support/CreateSupportCaseDialogContent';
|
||||||
|
|
||||||
|
interface SupportManagerProps {}
|
||||||
|
|
||||||
|
const SupportManager: React.FC<SupportManagerProps> = ({}) => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [supportCases, setSupportCases] = useState<SupportCaseApi[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||||
|
const [selectedSupportCaseId, setSelectedSupportCaseId] = useState<number | null>(null);
|
||||||
|
const [newMessageContent, setNewMessageContent] = useState<string>('');
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
const [errorMessages, setErrorMssages] = useState(false);
|
||||||
|
const [supportCase, setSupportCase] = useState<SupportCaseApi>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedSupportCase = searchParams.get('selectedSupportCase');
|
||||||
|
if (selectedSupportCase) {
|
||||||
|
console.log(selectedSupportCase);
|
||||||
|
setSelectedSupportCaseId(parseInt(selectedSupportCase, 10));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSupportCases = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
const { data }: AxiosResponse<SupportCaseApi[]> =
|
||||||
|
await axiosInstance.get('/support/cases/');
|
||||||
|
const data_with_messages: SupportCaseApi[] = data;
|
||||||
|
for (let i = 0; i < data_with_messages.length; i++) {
|
||||||
|
if (data_with_messages[i].messages === undefined) {
|
||||||
|
data_with_messages[i].messages = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
text: data_with_messages[i].description,
|
||||||
|
created_at: data_with_messages[i].created_at,
|
||||||
|
updated_at: data_with_messages[i].updated_at,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSupportCases(data_with_messages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSupportCases();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingMessages(true);
|
||||||
|
setErrorMssages(false);
|
||||||
|
const { data }: AxiosResponse<SupportCaseApi> = await axiosInstance.get(
|
||||||
|
`/support/cases/${selectedSupportCaseId}/`,
|
||||||
|
);
|
||||||
|
console.log(data);
|
||||||
|
// 1. Create a new SupportMessageApi object from the description
|
||||||
|
const descriptionMessage: SupportMessageApi = {
|
||||||
|
// You'll need to decide on values for these fields.
|
||||||
|
// Since this is a synthetic message based on the case description,
|
||||||
|
// you might use special values like -1 for id and 'System' for the user fields.
|
||||||
|
id: -1, // Use a unique or sentinel value
|
||||||
|
text: data.description,
|
||||||
|
user: -1, // Sentinel user ID
|
||||||
|
user_first_name: data.title, // Use the title as the sender/label
|
||||||
|
user_last_name: 'Description',
|
||||||
|
created_at: data.created_at, // Use the case's creation date
|
||||||
|
updated_at: data.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Create the updated SupportCaseApi object
|
||||||
|
const updatedSupportCase: SupportCaseApi = {
|
||||||
|
...data, // Keep all existing case data
|
||||||
|
// Prepend the new description message to the existing messages array
|
||||||
|
messages: [descriptionMessage, ...data.messages],
|
||||||
|
};
|
||||||
|
setSupportCase(updatedSupportCase);
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMssages(true);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessages(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMessages();
|
||||||
|
}, [selectedSupportCaseId]);
|
||||||
|
|
||||||
|
const handleOpenCreateSupportCaseDialog = () => {
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCreateSupportDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setSelectedSupportCaseId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSupportCase = async (
|
||||||
|
supportCase: Omit<SupportCaseApi, 'id' | 'status' | 'messages' | 'created_at' | 'updated_at'>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<SupportCaseApi> = await axiosInstance.post(
|
||||||
|
'/support/cases/',
|
||||||
|
supportCase,
|
||||||
|
);
|
||||||
|
setSupportCases((prevCases) => [...prevCases, response.data]);
|
||||||
|
handleCloseCreateSupportDialog();
|
||||||
|
setSelectedSupportCaseId(response.data.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create support case', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle sending a new message
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!newMessageContent.trim() || !selectedSupportCaseId) {
|
||||||
|
return; // Don't send empty messages or if no conversation is selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// send the message to the backend
|
||||||
|
try {
|
||||||
|
const { data }: AxiosResponse<SupportMessageApi> = await axiosInstance.post(
|
||||||
|
`/support/messages/`,
|
||||||
|
{
|
||||||
|
user: account?.id,
|
||||||
|
text: newMessageContent.trim(),
|
||||||
|
support_case: selectedSupportCaseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setSupportCases((prevConversations) =>
|
||||||
|
prevConversations.map((conv) =>
|
||||||
|
conv.id === selectedSupportCaseId
|
||||||
|
? {
|
||||||
|
...conv,
|
||||||
|
messages: [...conv.messages, data],
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
}
|
||||||
|
: conv,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setNewMessageContent(''); // Clear the input field
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <PageLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||||
|
<Typography variant="h6" color="error">
|
||||||
|
Failed to load Support Cases. Please try again later.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSupportCase = supportCases.find((item) => item.id === selectedSupportCaseId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', borderRadius: 3, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Grid container sx={{ height: '100%', width: '100%' }}>
|
||||||
|
{/* Left Panel: Conversation List */}
|
||||||
|
<Grid
|
||||||
|
size={{ xs: 12, md: 4 }}
|
||||||
|
sx={{
|
||||||
|
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'grey.200', display: 'flex' }}>
|
||||||
|
<Stack direction="row" sx={{ width: '100%' }}>
|
||||||
|
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Support Cases
|
||||||
|
</Typography>
|
||||||
|
{account?.user_type === 'property_owner' && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 'auto' }}
|
||||||
|
onClick={handleOpenCreateSupportCaseDialog}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<List sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
|
||||||
|
{supportCases.length === 0 ? (
|
||||||
|
<Box sx={{ p: 2, textAlign: 'center', color: 'grey.500' }}>
|
||||||
|
<QuestionMarkOutlined sx={{ fontSize: 40, mb: 1 }} />
|
||||||
|
<Typography>No support cases yet.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
supportCases
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||||
|
)
|
||||||
|
.map((conv) => (
|
||||||
|
<ListItem
|
||||||
|
key={conv.id}
|
||||||
|
button
|
||||||
|
selected={selectedSupportCaseId === conv.id}
|
||||||
|
onClick={() => setSelectedSupportCaseId(conv.id)}
|
||||||
|
sx={{ py: 1.5, px: 2 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{conv.title}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ flexGrow: 1, pr: 1 }}
|
||||||
|
>
|
||||||
|
{conv.status}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{formatTimestamp(conv.updated_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Right Panel: Conversation Detail */}
|
||||||
|
<Grid size={{ xs: 12, md: 8 }} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{supportCase ? (
|
||||||
|
<>
|
||||||
|
{/* Support Case Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*<Avatar sx={{ bgcolor: 'purple.200' }}>
|
||||||
|
{supportCase.title
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')}
|
||||||
|
</Avatar>*/}
|
||||||
|
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{supportCase.title} | {supportCase.category}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||||
|
{supportCase.messages.map((message) => (
|
||||||
|
<Box
|
||||||
|
key={message.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: message.user === account?.id ? 'flex-end' : 'flex-start',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: '75%',
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: message.user === account?.id ? 'purple.200' : 'lightblue.50',
|
||||||
|
color: 'grey.800',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 'medium', mb: 0.5 }}>
|
||||||
|
{message.user === account?.id ? 'You' : supportCase.withName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">{message.text}</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: 'block', textAlign: 'right', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{formatTimestamp(message.updated_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} /> {/* Scroll target */}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
value={newMessageContent}
|
||||||
|
onChange={(e) => setNewMessageContent(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={selectedSupportCaseId === null || supportCase.status !== 'opened'}
|
||||||
|
endIcon={<SendIcon />}
|
||||||
|
sx={{ px: 3, py: 1.2 }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 3,
|
||||||
|
color: 'grey.500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QuestionMarkOutlined sx={{ fontSize: 80, mb: 2 }} />
|
||||||
|
<Typography variant="h6">Select a support case to view messages</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Click on a support case from the left panel to get started.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
<CreateSupportCaseDialogContent
|
||||||
|
showDialog={showDialog}
|
||||||
|
closeDialog={handleCloseCreateSupportDialog}
|
||||||
|
createSupportCase={handleCreateSupportCase}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportManager;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactElement, useEffect, useState } from 'react';
|
import { ReactElement, useEffect, useState, useMemo } from 'react';
|
||||||
import { drawerWidth } from 'layouts/main-layout';
|
import { drawerWidth } from 'layouts/main-layout';
|
||||||
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
|
import { GenericCategory, VendorAPI, VendorCategory, VendorItem } from 'types';
|
||||||
import DashboardTemplate from 'components/DasboardTemplate';
|
import DashboardTemplate from 'components/DasboardTemplate';
|
||||||
@@ -8,10 +8,145 @@ import ItemListDetailTemplate from 'components/ItemListDetailTemplate';
|
|||||||
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
import VendorListItem from 'components/sections/dashboard/Home/Vendor/VendorListItem';
|
||||||
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
import VendorDetail from 'components/sections/dashboard/Home/Vendor/VendorDetail';
|
||||||
import { axiosInstance } from '../../axiosApi';
|
import { axiosInstance } from '../../axiosApi';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
const base_url: string = `${import.meta.env.VITE_API_URL?.replace('/api/', '')}/media/vendor_pictures/`;
|
||||||
|
|
||||||
|
// Define the array of corrected and alphabetized categories with 'as const'
|
||||||
|
const CATEGORY_NAMES = [
|
||||||
|
'Arborist',
|
||||||
|
'Basement Waterproofing And Injection',
|
||||||
|
'Carpenter',
|
||||||
|
'Cleaning Company',
|
||||||
|
'Decking',
|
||||||
|
'Door Company',
|
||||||
|
'Electrician',
|
||||||
|
'Fencing',
|
||||||
|
'General Contractor',
|
||||||
|
'Handyman',
|
||||||
|
'Home Inspector',
|
||||||
|
'House Staging',
|
||||||
|
'HVAC',
|
||||||
|
'Irrigation And Sprinkler System',
|
||||||
|
'Junk Removal',
|
||||||
|
'Landscaping',
|
||||||
|
'Masonry',
|
||||||
|
'Mortgage Lendor',
|
||||||
|
'Moving Company',
|
||||||
|
'Painter',
|
||||||
|
'Paving Company',
|
||||||
|
'Pest Control',
|
||||||
|
'Photographer',
|
||||||
|
'Plumber',
|
||||||
|
'Pressure Washing',
|
||||||
|
'Roofer',
|
||||||
|
'Storage Facility',
|
||||||
|
'Window Company',
|
||||||
|
'Window Washing',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const defaultCategoryImages: { [key in VendorType]: string } = {
|
||||||
|
Arborist: `${base_url}arborist.png`,
|
||||||
|
'Basement Waterproofing And Injection': `${base_url}basement.png`,
|
||||||
|
Carpenter: `${base_url}carpenter.png`,
|
||||||
|
'Cleaning Company': `${base_url}cleaning.png`,
|
||||||
|
Decking: `${base_url}deck.png`,
|
||||||
|
'Door Company': `${base_url}door.png`,
|
||||||
|
Electrician: `${base_url}electrician.png`,
|
||||||
|
Fencing: `${base_url}fencing.png`,
|
||||||
|
'General Contractor': `${base_url}general_contractor.png`,
|
||||||
|
Handyman: `${base_url}handyman.png`,
|
||||||
|
'Home Inspector': `${base_url}home_inspector.png`,
|
||||||
|
'House Staging': `${base_url}home_staging.png`,
|
||||||
|
HVAC: `${base_url}hvac.png`,
|
||||||
|
'Irrigation And Sprinkler System': `${base_url}irrigation.png`,
|
||||||
|
'Junk Removal': `${base_url}junk_removal.png`,
|
||||||
|
Landscaping: `${base_url}landscape.png`,
|
||||||
|
'Mortgage Lendor': `${base_url}landscape.png`,
|
||||||
|
Masonry: `${base_url}masonry.png`,
|
||||||
|
'Moving Company': `${base_url}junk_removal.png`,
|
||||||
|
Painter: `${base_url}painting.png`,
|
||||||
|
'Paving Company': `${base_url}paving.png`,
|
||||||
|
'Pest Control': `${base_url}pest_control.png`,
|
||||||
|
Photographer: `${base_url}photography.png`,
|
||||||
|
Plumber: `${base_url}plumber.jpg`,
|
||||||
|
'Pressure Washing': `${base_url}power_washing.png`,
|
||||||
|
Roofer: `${base_url}roofer.png`,
|
||||||
|
'Storage Facility': `${base_url}storage.png`,
|
||||||
|
'Window Company': `${base_url}window_installation.png`,
|
||||||
|
'Window Washing': `${base_url}window_company.jpg`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGRORY_DESCRIPTIONS: { [key in VendorType]: string } = {
|
||||||
|
Arborist:
|
||||||
|
'Connect with certified tree specialists for professional tree pruning, health assessment, and safe removal services. Keep your trees beautiful and your property protected.',
|
||||||
|
'Basement Waterproofing And Injection':
|
||||||
|
'Protect your biggest investment by finding experts in basement leak repair, foundation sealing, and effective moisture control solutions. Say goodbye to damp and musty spaces.',
|
||||||
|
Carpenter:
|
||||||
|
'Hire skilled craftsmen for custom built-ins, framing, trim work, and detailed wood repair projects around your home. Bring your vision of custom woodworking to life.',
|
||||||
|
'Cleaning Company': `Find reliable residential and commercial cleaning services to maintain a spotless and healthy environment. Whether it's a deep clean or regular maintenance, they handle the dirty work.`,
|
||||||
|
Decking:
|
||||||
|
'Design and build the perfect outdoor living space with professionals specializing in new deck construction, repairs, and staining. Get ready to enjoy your backyard retreat.',
|
||||||
|
'Door Company': `Install beautiful and secure entry, patio, and interior doors that enhance your home's curb appeal and energy efficiency. Upgrade your home's look and security.`,
|
||||||
|
Electrician:
|
||||||
|
'Get trusted service for wiring upgrades, lighting installation, electrical repairs, and safety inspections from licensed professionals. Power your home safely and efficiently.',
|
||||||
|
Fencing:
|
||||||
|
'Secure your property and enhance privacy with experts in wood, vinyl, metal, and chain-link fence installation and repair. Define your boundaries with style.',
|
||||||
|
'General Contractor':
|
||||||
|
'Oversee your entire home remodel, new construction, or major renovation project with a single trusted project manager. They coordinate all trades to complete your vision seamlessly.',
|
||||||
|
Handyman:
|
||||||
|
'For those small repairs and maintenance tasks that pile up, find a versatile professional to handle various jobs quickly and efficiently. Check off your to-do list with ease.',
|
||||||
|
'Home Inspector': `Get a detailed, unbiased report on the condition of a potential home purchase or sale from a certified inspector. Know exactly what you're buying or selling before you close.`,
|
||||||
|
'House Staging': `Prepare your home to sell faster and for a higher price with professional staging services that highlight your home's best features. Make a stunning first impression on potential buyers.`,
|
||||||
|
HVAC: 'Keep your home comfortable year-round with experts in heating, ventilation, and air conditioning system repair, maintenance, and new installation. Control your climate efficiently.',
|
||||||
|
'Irrigation And Sprinkler System': `Install, repair, and maintain efficient sprinkler systems that keep your lawn and garden healthy without wasting water. Automate your yard's watering needs.`,
|
||||||
|
'Junk Removal':
|
||||||
|
'Clear out clutter, construction debris, or unwanted items with fast and responsible hauling and disposal services. Reclaim your space and let them handle the heavy lifting.',
|
||||||
|
Landscaping:
|
||||||
|
'Transform your yard with design, installation, and maintenance services for beautiful lawns, gardens, and outdoor features. Create the perfect curb appeal and living space outside.',
|
||||||
|
Masonry:
|
||||||
|
'Restore or construct durable, attractive structures using brick, stone, and concrete for patios, walls, fireplaces, and foundations. Find skilled artisans for lasting results.',
|
||||||
|
'Mortgage Lendor':
|
||||||
|
'Connect with financing professionals to guide you through the process of securing a new home loan or refinancing your current one. Find the best rates and terms for your financial needs.',
|
||||||
|
'Moving Company':
|
||||||
|
'Hire reliable and insured movers for local or long-distance relocation, packing, and secure transportation of your belongings. Enjoy a smooth, stress-free move to your new home.',
|
||||||
|
Painter: `Refresh your home's interior and exterior with professional painting services, including prep work, color consultation, and a flawless finish. Give your home a vibrant new look.`,
|
||||||
|
'Paving Company':
|
||||||
|
'Install and repair durable asphalt and concrete surfaces for driveways, walkways, and patios. Find experts to enhance the accessibility and curb appeal of your property.',
|
||||||
|
'Pest Control':
|
||||||
|
'Protect your home from unwanted guests like insects, rodents, and wildlife with effective, preventative, and removal treatments. Keep your home safe and pest-free.',
|
||||||
|
Photographer:
|
||||||
|
'Capture stunning, high-quality images of your property for real estate listings, rentals, or design portfolios. Professional photos make a significant difference in marketing your home.',
|
||||||
|
Plumber:
|
||||||
|
'For leaks, clogs, fixture installation, and water heater repair, connect with licensed plumbing professionals for reliable service. Ensure your water systems are running smoothly.',
|
||||||
|
'Pressure Washing': `Revitalize your home's exterior, driveway, deck, and siding by removing dirt, grime, mold, and mildew. Bring back the original sparkle and brightness to your property.`,
|
||||||
|
Roofer:
|
||||||
|
'Find expert contractors for new roof installation, leak repair, and routine inspections to protect your home from the elements. Secure your home with a reliable, durable roof.',
|
||||||
|
'Storage Facility':
|
||||||
|
'Locate secure, convenient, and affordable short-term or long-term storage solutions for your personal or business belongings. Declutter your space without throwing things away.',
|
||||||
|
'Window Company':
|
||||||
|
'Upgrade your home with energy-efficient window replacement and installation to improve comfort and lower utility bills. Find the perfect styles to enhance natural light and aesthetics.',
|
||||||
|
'Window Washing':
|
||||||
|
'Schedule professional interior and exterior window cleaning to achieve streak-free, crystal-clear views. Let the natural light flood your home and boost your curb appeal.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type VendorType = (typeof CATEGORY_NAMES)[number];
|
||||||
const Vendors = (): ReactElement => {
|
const Vendors = (): ReactElement => {
|
||||||
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
|
const [allVendors, setAllVendors] = useState<VendorItem[]>([]);
|
||||||
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
|
const [vendorCategories, setVendorCategories] = useState<VendorCategory[]>([]);
|
||||||
|
const [hideEmptyCategories, setHideEmptyCategories] = useState<boolean>(true); // Default to true
|
||||||
|
const [searchRadius, setSearchRadius] = useState<number>(10);
|
||||||
|
|
||||||
// Simulate fetching data
|
// Simulate fetching data
|
||||||
let fetchedVendors: VendorItem[] = [];
|
let fetchedVendors: VendorItem[] = [];
|
||||||
@@ -51,42 +186,26 @@ const Vendors = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const defaultCategoryImages: { [key: string]: string } = {
|
CATEGORY_NAMES.forEach((categoryName) => {
|
||||||
electrician: 'https://via.placeholder.com/150/FF8C00/FFFFFF?text=Electrician',
|
const categoryId = categoryName.toLowerCase().replace(/\s+/g, '-');
|
||||||
plumber: 'https://via.placeholder.com/150/007bff/FFFFFF?text=Plumber',
|
categoryMap.set(categoryId, {
|
||||||
landscaping: 'https://via.placeholder.com/150/28a745/FFFFFF?text=Landscaping',
|
name: categoryName,
|
||||||
};
|
description: CATEGRORY_DESCRIPTIONS[categoryName],
|
||||||
|
imageUrl:
|
||||||
|
defaultCategoryImages[categoryName] ||
|
||||||
|
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
||||||
|
numVendors: 0,
|
||||||
|
totalRating: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fetchedVendors.forEach((vendor) => {
|
fetchedVendors.forEach((vendor) => {
|
||||||
const categoryId = vendor.categoryId;
|
const categoryId = vendor.categoryId;
|
||||||
if (!categoryMap.has(categoryId)) {
|
if (categoryMap.has(categoryId)) {
|
||||||
let categoryName = '';
|
const categoryData = categoryMap.get(categoryId)!;
|
||||||
switch (categoryId) {
|
categoryData.numVendors += 1;
|
||||||
case 'electrician':
|
categoryData.totalRating += vendor.rating;
|
||||||
categoryName = 'Electricians';
|
|
||||||
break;
|
|
||||||
case 'plumber':
|
|
||||||
categoryName = 'Plumbers';
|
|
||||||
break;
|
|
||||||
case 'landscaping':
|
|
||||||
categoryName = 'Landscaping';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
categoryName = 'Other Service';
|
|
||||||
}
|
|
||||||
categoryMap.set(categoryId, {
|
|
||||||
name: categoryName,
|
|
||||||
description: `Find expert ${categoryName.toLowerCase()} for your home and business.`,
|
|
||||||
imageUrl:
|
|
||||||
defaultCategoryImages[categoryId] ||
|
|
||||||
'https://via.placeholder.com/150/CCCCCC/000000?text=Category',
|
|
||||||
numVendors: 0,
|
|
||||||
totalRating: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const categoryData = categoryMap.get(categoryId)!;
|
|
||||||
categoryData.numVendors += 1;
|
|
||||||
categoryData.totalRating += vendor.rating;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
|
const processedCategories: VendorCategory[] = Array.from(categoryMap.entries()).map(
|
||||||
@@ -105,37 +224,110 @@ const Vendors = (): ReactElement => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filteredCategories = useMemo(() => {
|
||||||
|
// 1. Hide empty categories if the toggle is set to true
|
||||||
|
let categoriesToRender = vendorCategories;
|
||||||
|
console.log(vendorCategories);
|
||||||
|
if (hideEmptyCategories) {
|
||||||
|
categoriesToRender = vendorCategories.filter((cat) => cat.numVendors > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Geographic filtering based on searchRadius would go here.
|
||||||
|
// This would typically involve user location and an API call/client-side distance calculation
|
||||||
|
// filteredCategories = categoriesToRender.filter(category =>
|
||||||
|
// category.vendors.some(vendor => isWithinRadius(vendor.location, userLocation, searchRadius))
|
||||||
|
// );
|
||||||
|
|
||||||
|
return categoriesToRender;
|
||||||
|
}, [vendorCategories, hideEmptyCategories, searchRadius]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardTemplate<VendorCategory, VendorItem>
|
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||||
pageTitle="Service Vendors"
|
{/* VENDOR FILTERS HEADER AND CONTROLS */}
|
||||||
data={{ categories: vendorCategories, items: allVendors }}
|
<Paper
|
||||||
renderCategoryGrid={(categories, onSelectCategory) => (
|
sx={{
|
||||||
<CategoryGridTemplate
|
display: 'flex',
|
||||||
categories={categories}
|
justifyContent: 'space-between',
|
||||||
onSelectCategory={(id) => onSelectCategory(id)}
|
alignItems: 'center',
|
||||||
renderCategoryCard={(category, onSelect) => (
|
mb: 2,
|
||||||
<VendorCategoryCard category={category as VendorCategory} onSelectCategory={onSelect} />
|
flexWrap: 'wrap',
|
||||||
)}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>
|
||||||
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
Vendor Filters
|
||||||
<ItemListDetailTemplate
|
</Typography>
|
||||||
category={selectedCategory}
|
<Box
|
||||||
items={itemsInSelectedCategory}
|
sx={{
|
||||||
onBack={onBack}
|
display: 'flex',
|
||||||
renderListItem={(item, isSelected, onSelect) => (
|
gap: 2,
|
||||||
<VendorListItem
|
alignItems: 'center',
|
||||||
vendor={item as VendorItem}
|
mt: { xs: 1, sm: 0 },
|
||||||
isSelected={isSelected}
|
}}
|
||||||
onSelect={() => onSelect(item.id)}
|
>
|
||||||
/>
|
{/* TOGGLE: Hide Categories with No Vendors */}
|
||||||
)}
|
<FormControlLabel
|
||||||
renderItemDetail={(item) => (
|
control={
|
||||||
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
|
<Switch
|
||||||
)}
|
checked={hideEmptyCategories}
|
||||||
/>
|
onChange={(e) => setHideEmptyCategories(e.target.checked)}
|
||||||
)}
|
name="hideEmpty"
|
||||||
/>
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Hide Empty Categories"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DROPDOWN: Search Radius */}
|
||||||
|
{/*<FormControl variant="outlined" sx={{ minWidth: 120 }} size="small">
|
||||||
|
<InputLabel id="search-radius-label">Search Radius</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="search-radius-label"
|
||||||
|
id="search-radius-select"
|
||||||
|
value={searchRadius}
|
||||||
|
onChange={(e) => setSearchRadius(e.target.value as number)}
|
||||||
|
label="Search Radius"
|
||||||
|
>
|
||||||
|
<MenuItem value={10}>10 miles</MenuItem>
|
||||||
|
<MenuItem value={25}>25 miles</MenuItem>
|
||||||
|
<MenuItem value={50}>50 miles</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>*/}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
<DashboardTemplate<VendorCategory, VendorItem>
|
||||||
|
pageTitle="Service Vendors"
|
||||||
|
data={{ categories: filteredCategories, items: allVendors }}
|
||||||
|
renderCategoryGrid={(categories, onSelectCategory) => (
|
||||||
|
<CategoryGridTemplate
|
||||||
|
categories={categories}
|
||||||
|
onSelectCategory={(id) => onSelectCategory(id)}
|
||||||
|
renderCategoryCard={(category, onSelect) => (
|
||||||
|
<VendorCategoryCard
|
||||||
|
category={category as VendorCategory}
|
||||||
|
onSelectCategory={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItemListDetail={(selectedCategory, itemsInSelectedCategory, onBack) => (
|
||||||
|
<ItemListDetailTemplate
|
||||||
|
category={selectedCategory}
|
||||||
|
items={itemsInSelectedCategory}
|
||||||
|
onBack={onBack}
|
||||||
|
renderListItem={(item, isSelected, onSelect) => (
|
||||||
|
<VendorListItem
|
||||||
|
vendor={item as VendorItem}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={() => onSelect(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItemDetail={(item) => (
|
||||||
|
<VendorDetail vendor={item as VendorItem} showMessageBtn={true} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +1,205 @@
|
|||||||
import { ReactElement, Suspense, useContext, useState } from 'react';
|
import { ReactElement, Suspense, useContext, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Link,
|
Link,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import loginBanner from 'assets/authentication-banners/green.png';
|
import loginBanner from 'assets/authentication-banners/green.png';
|
||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
import logo from 'assets/logo/favicon-logo.png';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import Image from 'components/base/Image';
|
import Image from 'components/base/Image';
|
||||||
import{axiosInstance} from '../../axiosApi.js';
|
import { axiosInstance } from '../../axiosApi.js';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Form, Formik } from 'formik';
|
import { Form, Formik } from 'formik';
|
||||||
import { AuthContext } from 'contexts/AuthContext.js';
|
import { AuthContext } from 'contexts/AuthContext.js';
|
||||||
|
|
||||||
type loginValues = {
|
type loginValues = {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const Login = (): ReactElement => {
|
||||||
const Login = (): ReactElement => {
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
||||||
const handleClickShowPassword = () => setShowPassword(!showPassword);
|
|
||||||
|
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
const navigate = useNavigate();
|
||||||
const navigate = useNavigate();
|
const { setAuthentication } = useContext(AuthContext);
|
||||||
const {setAuthentication} = useContext(AuthContext);
|
|
||||||
|
const handleLogin = async ({ email, password }: loginValues): Promise<void> => {
|
||||||
const handleLogin = async({email, password}: loginValues): Promise<void> => {
|
try {
|
||||||
try{
|
const response = await axiosInstance.post('/token/', {
|
||||||
const response = await axiosInstance.post('/token/',
|
email: email,
|
||||||
{
|
password: password,
|
||||||
email: email,
|
});
|
||||||
password: password
|
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||||
}
|
localStorage.setItem('access_token', response.data.access);
|
||||||
)
|
localStorage.setItem('refresh_token', response.data.refresh);
|
||||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
const get_user_response = await axiosInstance.get('/user/');
|
||||||
localStorage.setItem('access_token', response.data.access);
|
|
||||||
localStorage.setItem('refresh_token', response.data.refresh);
|
setAuthentication(true);
|
||||||
const get_user_response = await axiosInstance.get('/user/')
|
|
||||||
|
navigate('/dashboard');
|
||||||
setAuthentication(true)
|
} catch (error) {
|
||||||
|
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||||
navigate("/")
|
if (hasErrors) {
|
||||||
|
setErrorMessage(error.response.data);
|
||||||
|
} else {
|
||||||
|
setErrorMessage(null);
|
||||||
}catch (error) {
|
}
|
||||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
}
|
||||||
if (hasErrors) {
|
};
|
||||||
setErrorMessage(error.response.data)
|
|
||||||
}else{
|
return (
|
||||||
setErrorMessage(null);
|
<Stack
|
||||||
}
|
direction="row"
|
||||||
}
|
bgcolor="background.paper"
|
||||||
}
|
boxShadow={(theme) => theme.shadows[3]}
|
||||||
|
height={560}
|
||||||
return (
|
width={{ md: 960 }}
|
||||||
<Stack
|
>
|
||||||
direction="row"
|
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||||
bgcolor="background.paper"
|
<Link href="/" height="fit-content">
|
||||||
boxShadow={(theme) => theme.shadows[3]}
|
<Image src={logo} width={82.6} />
|
||||||
height={560}
|
</Link>
|
||||||
width={{ md: 960 }}
|
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
||||||
>
|
<Typography variant="h3">Login</Typography>
|
||||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
<Formik
|
||||||
<Link href="/" height="fit-content">
|
initialValues={{
|
||||||
<Image src={logo} width={82.6} />
|
email: '',
|
||||||
</Link>
|
password: '',
|
||||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
}}
|
||||||
<Typography variant="h3">Login</Typography>
|
onSubmit={handleLogin}
|
||||||
<Formik
|
>
|
||||||
initialValues={{
|
{({ setFieldValue }) => (
|
||||||
email: '',
|
<Form>
|
||||||
password: '',
|
<FormControl variant="standard" fullWidth>
|
||||||
}}
|
{errorMessage ? (
|
||||||
onSubmit={handleLogin}
|
<Alert severity="error">
|
||||||
>
|
{errorMessage.detail ? (
|
||||||
{({setFieldValue}) => (
|
<Typography>{errorMessage.detail}</Typography>
|
||||||
|
) : (
|
||||||
<Form>
|
<ul>
|
||||||
<FormControl variant="standard" fullWidth>
|
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||||
{errorMessage ? (
|
<li key={fieldName}>
|
||||||
<Alert severity='error'>
|
<strong>{fieldName}</strong>
|
||||||
<ul>
|
{Array.isArray(errorMessages) ? (
|
||||||
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
<ul>
|
||||||
<li key={fieldName}>
|
{errorMessages.map((message, index) => (
|
||||||
<strong>{fieldName}</strong>
|
<li key={`${fieldName}-${index}`}>{message}</li>
|
||||||
{errorMessages.length > 0 ? (
|
))}
|
||||||
<ul>
|
</ul>
|
||||||
{errorMessages.map((message, index) => (
|
) : (
|
||||||
<li key={`${fieldName}-${index}`}>{message}</li> // Key for each message
|
<span>: {String(errorMessages)}</span>
|
||||||
))}
|
)}
|
||||||
</ul>
|
</li>
|
||||||
) : (
|
))}
|
||||||
<span> No specific errors for this field.</span>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</li>
|
</Alert>
|
||||||
))}
|
) : null}
|
||||||
</ul>
|
<InputLabel shrink htmlFor="email">
|
||||||
|
Email
|
||||||
</Alert>
|
</InputLabel>
|
||||||
|
<TextField
|
||||||
): null}
|
variant="filled"
|
||||||
<InputLabel shrink htmlFor="email">
|
placeholder="Enter your email"
|
||||||
Email
|
id="email"
|
||||||
</InputLabel>
|
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||||
<TextField
|
InputProps={{
|
||||||
variant="filled"
|
endAdornment: (
|
||||||
placeholder="Enter your email"
|
<InputAdornment position="end">
|
||||||
id="email"
|
<IconifyIcon icon="ic:baseline-email" />
|
||||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
</InputAdornment>
|
||||||
InputProps={{
|
),
|
||||||
endAdornment: (
|
}}
|
||||||
<InputAdornment position="end">
|
/>
|
||||||
<IconifyIcon icon="ic:baseline-email" />
|
</FormControl>
|
||||||
</InputAdornment>
|
<FormControl variant="standard" fullWidth>
|
||||||
),
|
<InputLabel shrink htmlFor="password">
|
||||||
}}
|
Password
|
||||||
/>
|
</InputLabel>
|
||||||
</FormControl>
|
<TextField
|
||||||
<FormControl variant="standard" fullWidth>
|
variant="filled"
|
||||||
<InputLabel shrink htmlFor="password">
|
placeholder="********"
|
||||||
Password
|
onChange={(event) => setFieldValue('password', event.target.value)}
|
||||||
</InputLabel>
|
type={showPassword ? 'text' : 'password'}
|
||||||
<TextField
|
id="password"
|
||||||
variant="filled"
|
InputProps={{
|
||||||
placeholder="********"
|
endAdornment: (
|
||||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
<InputAdornment position="end">
|
||||||
type={showPassword ? 'text' : 'password'}
|
<IconButton
|
||||||
id="password"
|
aria-label="toggle password visibility"
|
||||||
InputProps={{
|
onClick={handleClickShowPassword}
|
||||||
endAdornment: (
|
edge="end"
|
||||||
<InputAdornment position="end">
|
sx={{
|
||||||
<IconButton
|
color: 'text.secondary',
|
||||||
aria-label="toggle password visibility"
|
}}
|
||||||
onClick={handleClickShowPassword}
|
>
|
||||||
edge="end"
|
{showPassword ? (
|
||||||
sx={{
|
<IconifyIcon icon="ic:baseline-key-off" />
|
||||||
color: 'text.secondary',
|
) : (
|
||||||
}}
|
<IconifyIcon icon="ic:baseline-key" />
|
||||||
>
|
)}
|
||||||
{showPassword ? (
|
</IconButton>
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
</InputAdornment>
|
||||||
) : (
|
),
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
}}
|
||||||
)}
|
/>
|
||||||
</IconButton>
|
</FormControl>
|
||||||
</InputAdornment>
|
<Typography
|
||||||
),
|
variant="body1"
|
||||||
}}
|
sx={{
|
||||||
/>
|
alignSelf: 'flex-end',
|
||||||
</FormControl>
|
}}
|
||||||
<Typography
|
>
|
||||||
variant="body1"
|
<Link href="/authentication/forgot-password" underline="hover">
|
||||||
sx={{
|
Forget password
|
||||||
alignSelf: 'flex-end',
|
</Link>
|
||||||
}}
|
</Typography>
|
||||||
>
|
<Button variant="contained" type={'submit'} fullWidth>
|
||||||
<Link href="/authentication/forgot-password" underline="hover">
|
Log in
|
||||||
Forget password
|
</Button>
|
||||||
</Link>
|
</Form>
|
||||||
</Typography>
|
)}
|
||||||
<Button variant="contained" type={'submit'} fullWidth>
|
</Formik>
|
||||||
Log in
|
<Typography variant="body2" color="text.secondary">
|
||||||
</Button>
|
Don't have an account ?{' '}
|
||||||
</Form>
|
<Link
|
||||||
)}
|
href="/authentication/sign-up"
|
||||||
</Formik>
|
underline="hover"
|
||||||
<Typography variant="body2" color="text.secondary">
|
fontSize={(theme) => theme.typography.body1.fontSize}
|
||||||
Don't have an account ?{' '}
|
>
|
||||||
<Link
|
Sign up
|
||||||
href="/authentication/sign-up"
|
</Link>
|
||||||
underline="hover"
|
</Typography>
|
||||||
fontSize={(theme) => theme.typography.body1.fontSize}
|
</Stack>
|
||||||
>
|
</Stack>
|
||||||
Sign up
|
<Suspense
|
||||||
</Link>
|
fallback={
|
||||||
</Typography>
|
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||||
</Stack>
|
}
|
||||||
</Stack>
|
>
|
||||||
<Suspense
|
<Image
|
||||||
fallback={
|
alt="Login banner"
|
||||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
src={loginBanner}
|
||||||
}
|
sx={{
|
||||||
>
|
width: 0.5,
|
||||||
<Image
|
display: { xs: 'none', md: 'block' },
|
||||||
alt="Login banner"
|
}}
|
||||||
src={loginBanner}
|
/>
|
||||||
sx={{
|
</Suspense>
|
||||||
width: 0.5,
|
</Stack>
|
||||||
display: { xs: 'none', md: 'block' },
|
);
|
||||||
}}
|
};
|
||||||
/>
|
|
||||||
</Suspense>
|
export default Login;
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Login;
|
|
||||||
|
|||||||
@@ -1,166 +1,170 @@
|
|||||||
import { ReactElement, Suspense, useState } from 'react';
|
import { ReactElement, Suspense, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Link,
|
Link,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import logo from 'assets/logo/favicon-logo.png';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import resetPassword from 'assets/authentication-banners/green.png';
|
import resetPassword from 'assets/authentication-banners/green.png';
|
||||||
import passwordUpdated from 'assets/authentication-banners/password-updated.png';
|
import passwordUpdated from 'assets/authentication-banners/password-updated.png';
|
||||||
import successTick from 'assets/authentication-banners/successTick.png';
|
import successTick from 'assets/authentication-banners/successTick.png';
|
||||||
import Image from 'components/base/Image';
|
import Image from 'components/base/Image';
|
||||||
import IconifyIcon from 'components/base/IconifyIcon';
|
import IconifyIcon from 'components/base/IconifyIcon';
|
||||||
|
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
|
||||||
const ResetPassword = (): ReactElement => {
|
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const ResetPassword = (): ReactElement => {
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
|
const [password, setPassword] = useState('');
|
||||||
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
|
|
||||||
const [resetSuccessful, setResetSuccessful] = useState(false);
|
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
|
||||||
|
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
|
||||||
const handleResetPassword = () => {
|
const [resetSuccessful, setResetSuccessful] = useState(false);
|
||||||
const passwordField: HTMLInputElement = document.getElementById(
|
|
||||||
'new-password',
|
const handleResetPassword = () => {
|
||||||
) as HTMLInputElement;
|
const passwordField: HTMLInputElement = document.getElementById(
|
||||||
const confirmPasswordField: HTMLInputElement = document.getElementById(
|
'new-password',
|
||||||
'confirm-password',
|
) as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const confirmPasswordField: HTMLInputElement = document.getElementById(
|
||||||
|
'confirm-password',
|
||||||
if (passwordField.value !== confirmPasswordField.value) {
|
) as HTMLInputElement;
|
||||||
alert("Passwords don't match");
|
|
||||||
return;
|
if (passwordField.value !== confirmPasswordField.value) {
|
||||||
}
|
alert("Passwords don't match");
|
||||||
setResetSuccessful(true);
|
return;
|
||||||
};
|
}
|
||||||
|
setResetSuccessful(true);
|
||||||
return (
|
};
|
||||||
<Stack
|
|
||||||
direction="row"
|
return (
|
||||||
bgcolor="background.paper"
|
<Stack
|
||||||
boxShadow={(theme) => theme.shadows[3]}
|
direction="row"
|
||||||
height={560}
|
bgcolor="background.paper"
|
||||||
width={{ md: 960 }}
|
boxShadow={(theme) => theme.shadows[3]}
|
||||||
>
|
height={560}
|
||||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
width={{ md: 960 }}
|
||||||
<Link href="/" width="fit-content">
|
>
|
||||||
<Image src={logo} width={82.6} />
|
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||||
</Link>
|
<Link href="/" width="fit-content">
|
||||||
{!resetSuccessful ? (
|
<Image src={logo} width={82.6} />
|
||||||
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
</Link>
|
||||||
<Typography variant="h3">Reset Password</Typography>
|
{!resetSuccessful ? (
|
||||||
<FormControl variant="standard" fullWidth>
|
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
||||||
<InputLabel shrink htmlFor="new-password">
|
<Typography variant="h3">Reset Password</Typography>
|
||||||
Password
|
<FormControl variant="standard" fullWidth>
|
||||||
</InputLabel>
|
<InputLabel shrink htmlFor="new-password">
|
||||||
<TextField
|
Password
|
||||||
variant="filled"
|
</InputLabel>
|
||||||
placeholder="Enter new password"
|
<TextField
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
variant="filled"
|
||||||
id="new-password"
|
placeholder="Enter new password"
|
||||||
InputProps={{
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
endAdornment: (
|
id="new-password"
|
||||||
<InputAdornment position="end">
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
<IconButton
|
InputProps={{
|
||||||
aria-label="toggle password visibility"
|
endAdornment: (
|
||||||
onClick={handleClickShowNewPassword}
|
<InputAdornment position="end">
|
||||||
edge="end"
|
<IconButton
|
||||||
sx={{
|
aria-label="toggle password visibility"
|
||||||
color: 'text.secondary',
|
onClick={handleClickShowNewPassword}
|
||||||
}}
|
edge="end"
|
||||||
>
|
sx={{
|
||||||
{showNewPassword ? (
|
color: 'text.secondary',
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
}}
|
||||||
) : (
|
>
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
{showNewPassword ? (
|
||||||
)}
|
<IconifyIcon icon="ic:baseline-key-off" />
|
||||||
</IconButton>
|
) : (
|
||||||
</InputAdornment>
|
<IconifyIcon icon="ic:baseline-key" />
|
||||||
),
|
)}
|
||||||
}}
|
</IconButton>
|
||||||
/>
|
</InputAdornment>
|
||||||
</FormControl>
|
),
|
||||||
<FormControl variant="standard" fullWidth>
|
}}
|
||||||
<InputLabel shrink htmlFor="confirm-password">
|
/>
|
||||||
Password
|
<PasswordStrengthChecker password={password} />
|
||||||
</InputLabel>
|
</FormControl>
|
||||||
<TextField
|
<FormControl variant="standard" fullWidth>
|
||||||
variant="filled"
|
<InputLabel shrink htmlFor="confirm-password">
|
||||||
placeholder="Confirm password"
|
Password
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
</InputLabel>
|
||||||
id="confirm-password"
|
<TextField
|
||||||
InputProps={{
|
variant="filled"
|
||||||
endAdornment: (
|
placeholder="Confirm password"
|
||||||
<InputAdornment position="end">
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
<IconButton
|
id="confirm-password"
|
||||||
aria-label="toggle password visibility"
|
InputProps={{
|
||||||
onClick={handleClickShowConfirmPassword}
|
endAdornment: (
|
||||||
edge="end"
|
<InputAdornment position="end">
|
||||||
sx={{
|
<IconButton
|
||||||
color: 'text.secondary',
|
aria-label="toggle password visibility"
|
||||||
}}
|
onClick={handleClickShowConfirmPassword}
|
||||||
>
|
edge="end"
|
||||||
{showConfirmPassword ? (
|
sx={{
|
||||||
<IconifyIcon icon="ic:baseline-key-off" />
|
color: 'text.secondary',
|
||||||
) : (
|
}}
|
||||||
<IconifyIcon icon="ic:baseline-key" />
|
>
|
||||||
)}
|
{showConfirmPassword ? (
|
||||||
</IconButton>
|
<IconifyIcon icon="ic:baseline-key-off" />
|
||||||
</InputAdornment>
|
) : (
|
||||||
),
|
<IconifyIcon icon="ic:baseline-key" />
|
||||||
}}
|
)}
|
||||||
/>
|
</IconButton>
|
||||||
</FormControl>
|
</InputAdornment>
|
||||||
<Button variant="contained" fullWidth onClick={handleResetPassword}>
|
),
|
||||||
Reset Password
|
}}
|
||||||
</Button>
|
/>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</FormControl>
|
||||||
Back to{' '}
|
<Button variant="contained" fullWidth onClick={handleResetPassword}>
|
||||||
<Link
|
Reset Password
|
||||||
href="/authentication/login"
|
</Button>
|
||||||
underline="hover"
|
<Typography variant="body2" color="text.secondary">
|
||||||
fontSize={(theme) => theme.typography.body1.fontSize}
|
Back to{' '}
|
||||||
>
|
<Link
|
||||||
Log in
|
href="/authentication/login"
|
||||||
</Link>
|
underline="hover"
|
||||||
</Typography>
|
fontSize={(theme) => theme.typography.body1.fontSize}
|
||||||
</Stack>
|
>
|
||||||
) : (
|
Log in
|
||||||
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
</Link>
|
||||||
<Image src={successTick} />
|
</Typography>
|
||||||
<Typography variant="h3">Reset Successfully</Typography>
|
</Stack>
|
||||||
<Typography variant="body1" textAlign="center" color="text.secndary">
|
) : (
|
||||||
Your Ditch the Agent log in password has been updated successfully
|
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
||||||
</Typography>
|
<Image src={successTick} />
|
||||||
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">
|
<Typography variant="h3">Reset Successfully</Typography>
|
||||||
Continue to Login
|
<Typography variant="body1" textAlign="center" color="text.secndary">
|
||||||
</Button>
|
Your Ditch the Agent log in password has been updated successfully
|
||||||
</Stack>
|
</Typography>
|
||||||
)}
|
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">
|
||||||
</Stack>
|
Continue to Login
|
||||||
<Suspense
|
</Button>
|
||||||
fallback={
|
</Stack>
|
||||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
)}
|
||||||
}
|
</Stack>
|
||||||
>
|
<Suspense
|
||||||
<Image
|
fallback={
|
||||||
alt={resetSuccessful ? 'Reset done' : 'Login banner'}
|
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||||
src={resetSuccessful ? passwordUpdated : resetPassword}
|
}
|
||||||
sx={{
|
>
|
||||||
width: 0.5,
|
<Image
|
||||||
display: { xs: 'none', md: 'block' },
|
alt={resetSuccessful ? 'Reset done' : 'Login banner'}
|
||||||
}}
|
src={resetSuccessful ? passwordUpdated : resetPassword}
|
||||||
/>
|
sx={{
|
||||||
</Suspense>
|
width: 0.5,
|
||||||
</Stack>
|
display: { xs: 'none', md: 'block' },
|
||||||
);
|
}}
|
||||||
};
|
/>
|
||||||
|
</Suspense>
|
||||||
export default ResetPassword;
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPassword;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import IconifyIcon from 'components/base/IconifyIcon';
|
|||||||
import logo from 'assets/logo/favicon-logo.png';
|
import logo from 'assets/logo/favicon-logo.png';
|
||||||
import Image from 'components/base/Image';
|
import Image from 'components/base/Image';
|
||||||
import { axiosInstance } from '../../axiosApi.js';
|
import { axiosInstance } from '../../axiosApi.js';
|
||||||
|
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
type SignUpValues = {
|
type SignUpValues = {
|
||||||
@@ -36,6 +37,7 @@ type SignUpValues = {
|
|||||||
const SignUp = (): ReactElement => {
|
const SignUp = (): ReactElement => {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showPassword2, setShowPassword2] = useState(false);
|
const [showPassword2, setShowPassword2] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||||
|
|
||||||
@@ -217,7 +219,10 @@ const SignUp = (): ReactElement => {
|
|||||||
<TextField
|
<TextField
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="********"
|
placeholder="********"
|
||||||
onChange={(event) => setFieldValue('password', event.target.value)}
|
onChange={(event) => {
|
||||||
|
setFieldValue('password', event.target.value);
|
||||||
|
setPassword(event.target.value);
|
||||||
|
}}
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
id="password"
|
id="password"
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -241,6 +246,7 @@ const SignUp = (): ReactElement => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<PasswordStrengthChecker password={password} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl variant="standard" fullWidth>
|
<FormControl variant="standard" fullWidth>
|
||||||
<InputLabel shrink htmlFor="password">
|
<InputLabel shrink htmlFor="password">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const rootPaths = {
|
export const rootPaths = {
|
||||||
homeRoot: '',
|
homeRoot: '',
|
||||||
|
dashboardRoot: 'dashboard',
|
||||||
pagesRoot: 'pages',
|
pagesRoot: 'pages',
|
||||||
applicationsRoot: 'applications',
|
applicationsRoot: 'applications',
|
||||||
ecommerceRoot: 'ecommerce',
|
ecommerceRoot: 'ecommerce',
|
||||||
@@ -16,12 +17,17 @@ export const rootPaths = {
|
|||||||
profileRoot: 'profile',
|
profileRoot: 'profile',
|
||||||
offersRoot: 'offers',
|
offersRoot: 'offers',
|
||||||
bidsRoot: 'bids',
|
bidsRoot: 'bids',
|
||||||
|
documentsRoot: 'documents',
|
||||||
vendorBidsRoot: 'vendor-bids',
|
vendorBidsRoot: 'vendor-bids',
|
||||||
upgradeRoot: 'upgrade',
|
upgradeRoot: 'upgrade',
|
||||||
|
propertySearchRoot: 'property-search',
|
||||||
|
supportRoot: 'support',
|
||||||
|
publicRoot: 'public',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
home: `/${rootPaths.homeRoot}`,
|
home: `/${rootPaths.homeRoot}`,
|
||||||
|
dashboard: `/${rootPaths.dashboardRoot}`,
|
||||||
login: `/${rootPaths.authRoot}/login`,
|
login: `/${rootPaths.authRoot}/login`,
|
||||||
signup: `/${rootPaths.authRoot}/sign-up`,
|
signup: `/${rootPaths.authRoot}/sign-up`,
|
||||||
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
||||||
@@ -31,7 +37,9 @@ export default {
|
|||||||
educationLesson: `/${rootPaths.educationRoot}/lesson`,
|
educationLesson: `/${rootPaths.educationRoot}/lesson`,
|
||||||
property: `/${rootPaths.propertyRoot}`,
|
property: `/${rootPaths.propertyRoot}`,
|
||||||
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
|
propertyDetail: `/${rootPaths.propertyRoot}/:propertyId`,
|
||||||
propertySearch: `/${rootPaths.propertyRoot}/search`,
|
propertySearch: `/${rootPaths.propertySearchRoot}`,
|
||||||
|
publicPropertySearch: `/${rootPaths.homeRoot}`,
|
||||||
|
publicPropertyDetail: `/${rootPaths.publicRoot}/:propertyId`,
|
||||||
vendors: `/${rootPaths.vendorsRoot}`,
|
vendors: `/${rootPaths.vendorsRoot}`,
|
||||||
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
|
termsOfService: `/${rootPaths.termsOfServiceRoot}`,
|
||||||
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
|
mortageCalculator: `/${rootPaths.toolsRoot}/mortgage-calculator`,
|
||||||
@@ -43,6 +51,9 @@ export default {
|
|||||||
bids: `/${rootPaths.bidsRoot}/`,
|
bids: `/${rootPaths.bidsRoot}/`,
|
||||||
vendorBids: `/${rootPaths.vendorBidsRoot}/`,
|
vendorBids: `/${rootPaths.vendorBidsRoot}/`,
|
||||||
upgrade: `/${rootPaths.upgradeRoot}/`,
|
upgrade: `/${rootPaths.upgradeRoot}/`,
|
||||||
|
documents: `/${rootPaths.documentsRoot}/`,
|
||||||
|
support: `/${rootPaths.supportRoot}/`,
|
||||||
|
supportManager: `/${rootPaths.supportRoot}/manager/`,
|
||||||
|
|
||||||
// need to do these pages
|
// need to do these pages
|
||||||
profile: `/${rootPaths.profileRoot}/`,
|
profile: `/${rootPaths.profileRoot}/`,
|
||||||
|
|||||||
@@ -29,9 +29,25 @@ import ProfilePage from 'pages/Profile/Profile';
|
|||||||
import Dashboard from 'pages/home/Dashboard';
|
import Dashboard from 'pages/home/Dashboard';
|
||||||
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
|
import PropertyDetailPage from 'pages/Property/PropertyDetailPage';
|
||||||
import PropertySearchPage from 'pages/Property/PropertySearchPage';
|
import PropertySearchPage from 'pages/Property/PropertySearchPage';
|
||||||
|
import PublicPropertySearch from 'pages/Property/PublicPropertySearch';
|
||||||
import BidsPage from 'pages/Bids/Bids';
|
import BidsPage from 'pages/Bids/Bids';
|
||||||
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
|
import VendorBidsPage from 'components/sections/dashboard/Home/Bids/VendorBids';
|
||||||
import UpgradePage from 'pages/Upgrade/UpgradePage';
|
import UpgradePage from 'pages/Upgrade/UpgradePage';
|
||||||
|
import DocumentManager from 'components/sections/dashboard/Home/Documents/DocumentManager';
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import PublicPropertyDetail from 'pages/Property/PublicPropertyDetail';
|
||||||
|
import FAQPage from 'pages/Support/FAQ';
|
||||||
|
import SupportManager from 'pages/Support/SupportManager';
|
||||||
|
|
||||||
|
const RootRedirect = () => {
|
||||||
|
const { authenticated } = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (authenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PublicPropertySearch />;
|
||||||
|
};
|
||||||
|
|
||||||
const App = lazy(() => import('App'));
|
const App = lazy(() => import('App'));
|
||||||
const MainLayout = lazy(async () => {
|
const MainLayout = lazy(async () => {
|
||||||
@@ -47,6 +63,13 @@ const AuthLayout = lazy(async () => {
|
|||||||
]).then(([moduleExports]) => moduleExports);
|
]).then(([moduleExports]) => moduleExports);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PublicLayout = lazy(async () => {
|
||||||
|
return Promise.all([
|
||||||
|
import('layouts/public-layout'),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||||
|
]).then(([moduleExports]) => moduleExports);
|
||||||
|
});
|
||||||
|
|
||||||
const Error404 = lazy(async () => {
|
const Error404 = lazy(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
return import('pages/errors/Error404');
|
return import('pages/errors/Error404');
|
||||||
@@ -92,7 +115,7 @@ const routes: RouteObject[] = [
|
|||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: rootPaths.homeRoot,
|
path: rootPaths.dashboardRoot,
|
||||||
element: (
|
element: (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
@@ -104,9 +127,41 @@ const routes: RouteObject[] = [
|
|||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: paths.home,
|
path: paths.dashboard,
|
||||||
element: <Dashboard />,
|
element: <Dashboard />,
|
||||||
//element: <Sales />,
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: rootPaths.homeRoot,
|
||||||
|
element: (
|
||||||
|
<PublicLayout>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</PublicLayout>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: paths.publicPropertySearch,
|
||||||
|
element: <RootRedirect />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: rootPaths.publicRoot,
|
||||||
|
element: (
|
||||||
|
<PublicLayout>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</PublicLayout>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: paths.publicPropertyDetail,
|
||||||
|
element: <PublicPropertyDetail />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -175,6 +230,20 @@ const routes: RouteObject[] = [
|
|||||||
path: paths.propertyDetail,
|
path: paths.propertyDetail,
|
||||||
element: <PropertyDetailPage />,
|
element: <PropertyDetailPage />,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: rootPaths.propertySearchRoot,
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</MainLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
path: paths.propertySearch,
|
path: paths.propertySearch,
|
||||||
element: <PropertySearchPage />,
|
element: <PropertySearchPage />,
|
||||||
@@ -259,6 +328,25 @@ const routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: rootPaths.documentsRoot,
|
||||||
|
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</MainLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: paths.documents,
|
||||||
|
element: <DocumentManager />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: rootPaths.bidsRoot,
|
path: rootPaths.bidsRoot,
|
||||||
|
|
||||||
@@ -316,6 +404,29 @@ const routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: rootPaths.supportRoot,
|
||||||
|
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</MainLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: paths.support,
|
||||||
|
element: <FAQPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: paths.supportManager,
|
||||||
|
element: <SupportManager />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: rootPaths.profileRoot,
|
path: rootPaths.profileRoot,
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// src/templates/types.ts
|
// src/templates/types.ts
|
||||||
|
|
||||||
|
import { HomeImprovementReceiptData } from 'components/sections/dashboard/Home/Documents/Dialog/HomeImprovementReciptDialogContent';
|
||||||
|
import { OfferData } from 'components/sections/dashboard/Home/Documents/Dialog/OfferDialogContent';
|
||||||
|
import { SellerDisclousureData } from 'components/sections/dashboard/Home/Documents/Dialog/SellerDisclousureDialogContent';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -105,6 +109,14 @@ export interface PropertyOwnerAPI {
|
|||||||
phone_number: string;
|
phone_number: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenHouseAPI {
|
||||||
|
id: number;
|
||||||
|
property: number | PropertiesAPI; // It can be an ID or the full object
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
listed_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VendorAPI {
|
export interface VendorAPI {
|
||||||
user: UserAPI;
|
user: UserAPI;
|
||||||
business_name: string;
|
business_name: string;
|
||||||
@@ -222,10 +234,6 @@ export interface TaxHistoryAPI {
|
|||||||
year: number;
|
year: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenHouseAPI {
|
|
||||||
lsited_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchoolAPI {
|
export interface SchoolAPI {
|
||||||
id?: number;
|
id?: number;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -297,6 +305,7 @@ export interface PropertiesAPI {
|
|||||||
tax_info: TaxHistoryAPI;
|
tax_info: TaxHistoryAPI;
|
||||||
open_houses?: OpenHouseAPI[];
|
open_houses?: OpenHouseAPI[];
|
||||||
schools: SchoolAPI[];
|
schools: SchoolAPI[];
|
||||||
|
documents?: DocumentAPI[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BidImageAPI {
|
export interface BidImageAPI {
|
||||||
@@ -327,6 +336,19 @@ export interface BidAPI {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentAPI {
|
||||||
|
id: number;
|
||||||
|
property: number;
|
||||||
|
file: string;
|
||||||
|
document_type: string;
|
||||||
|
description?: string;
|
||||||
|
uploaded_by: number; // or a more detailed user object
|
||||||
|
shared_with: number[];
|
||||||
|
updated_at: string;
|
||||||
|
created_at: string;
|
||||||
|
sub_document?: SellerDisclousureData | HomeImprovementReceiptData | OfferData;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
|
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
|
||||||
owner: number;
|
owner: number;
|
||||||
}
|
}
|
||||||
@@ -711,4 +733,37 @@ export interface PropertyResponseAPI {
|
|||||||
compsLookupExecutionTimeMS: string;
|
compsLookupExecutionTimeMS: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedPropertiesAPI {
|
||||||
|
id: number;
|
||||||
|
user: number;
|
||||||
|
property: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaqApi {
|
||||||
|
order: number;
|
||||||
|
answer: string;
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportMessageApi {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
user: id;
|
||||||
|
user_first_name: string;
|
||||||
|
user_last_name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportCaseApi {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
messages: SupportMessageApi[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
// Walk Score API Type Definitions
|
// Walk Score API Type Definitions
|
||||||
|
|||||||
@@ -24,3 +24,16 @@ export function extractLatLon(pointString: string): { latitude: number; longitud
|
|||||||
}
|
}
|
||||||
return { latitude: 0, longitude: 0 }; // Return null if the string format is not as expected or parsing fails
|
return { latitude: 0, longitude: 0 }; // Return null if the string format is not as expected or parsing fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number as a currency string using US dollar locale.
|
||||||
|
*
|
||||||
|
* @param value The number to format.
|
||||||
|
* @returns A string representing the formatted currency value (e.g., "$1,234.56").
|
||||||
|
*/
|
||||||
|
export const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user