Compare commits
3 Commits
1570185792
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d4514c998a | |||
| a6f267492d | |||
| 173f6ccf6d |
2436
ditch-the-agent/package-lock.json
generated
2436
ditch-the-agent/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
|
||||
"deploy": "gh-pages -d dist"
|
||||
"deploy": "gh-pages -d dist",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
@@ -43,6 +44,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^4.1.1",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^14.2.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
@@ -52,8 +56,10 @@
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"jsdom": "^24.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react';
|
||||
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI } from 'types';
|
||||
import { axiosInstance } from '../../../../../axiosApi';
|
||||
//==import Grid from '@mui/material/Unstable_Grid2';
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { drawerWidth } from 'layouts/main-layout';
|
||||
import { ProperyInfoCards } from '../Property/PropertyInfo';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import HouseIcon from '@mui/icons-material/House';
|
||||
@@ -109,7 +108,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||
try {
|
||||
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||
`/properties/${updatedProperty.id}/`,
|
||||
`/properties/${updatedProperty.uuid4}/`,
|
||||
{
|
||||
...updatedProperty,
|
||||
owner: account.id,
|
||||
@@ -130,10 +129,10 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = async (propertyId: number) => {
|
||||
const handleDeleteProperty = async (propertyId: string) => {
|
||||
try {
|
||||
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||
setProperties((prev) => prev.filter((item) => item.uuid4 !== propertyId));
|
||||
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
@@ -203,13 +202,13 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
>
|
||||
{/* quick states */}
|
||||
{!account.profile_created && (
|
||||
<Grid xs={12} key="profile-setup">
|
||||
<Grid item xs={12} key="profile-setup">
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Please set up your <a href="/profile">profile</a>
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="active-listing-card">
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key="active-listing-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/profile')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
@@ -220,7 +219,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-views-card">
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key="num-views-card">
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
@@ -231,7 +230,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="total-saves-card">
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key="total-saves-card">
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
@@ -242,7 +241,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-offers-card">
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key="num-offers-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/offers')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
@@ -253,7 +252,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} md={4} lg={3} key="num-bids-card">
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key="num-bids-card">
|
||||
<Card sx={{ display: 'flex' }} onClick={() => navigate('/bids')}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
@@ -266,7 +265,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Grid>
|
||||
|
||||
{account.tier === 'basic' && (
|
||||
<Grid xs={12} md={4}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Upgrade your account</Typography>
|
||||
@@ -292,12 +291,12 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Alert>
|
||||
)}
|
||||
{properties.length > 0 && (
|
||||
<Grid xs={12}>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
)}
|
||||
{properties.map((item) => (
|
||||
<Grid xs={12} key={item.id}>
|
||||
<Grid item xs={12} key={item.id}>
|
||||
<PropertyDetailCard
|
||||
property={item}
|
||||
isPublicPage={false}
|
||||
@@ -308,11 +307,11 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
{/* <ProperyInfoCards property={item} /> */}
|
||||
</Grid>
|
||||
))}
|
||||
<Grid xs={12}>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={documentsCardLength}>
|
||||
<Grid item xs={12} md={documentsCardLength}>
|
||||
<Card sx={{ display: 'flex' }}>
|
||||
<Stack direction="column">
|
||||
<Typography variant="h4">Documents Requiring Attention</Typography>
|
||||
@@ -329,7 +328,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={savedPropertiesCardLength}>
|
||||
<Grid item xs={12} md={savedPropertiesCardLength}>
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
<Stack direction="column">
|
||||
@@ -343,7 +342,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid xs={12} md={12}>
|
||||
<Grid item xs={12} md={12}>
|
||||
{account.tier === 'premium' ? (
|
||||
<Card>
|
||||
<Stack direction="column">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { axiosInstance } from '../../../../../axiosApi';
|
||||
|
||||
interface AttorneyEngagementLetterDisplayProps {
|
||||
letterData: AttorneyEngagementLetterData;
|
||||
documentId: number;
|
||||
documentId: string;
|
||||
onSignSuccess?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
|
||||
useEffect(() => {
|
||||
const selectedDocumentId = searchParams.get('selectedDocument');
|
||||
if (selectedDocumentId) {
|
||||
fetchDocument(parseInt(selectedDocumentId, 10));
|
||||
fetchDocument(selectedDocumentId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -145,7 +145,7 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
|
||||
|
||||
console.log(documents);
|
||||
|
||||
const fetchDocument = async (documentId: number) => {
|
||||
const fetchDocument = async (documentId: string) => {
|
||||
try {
|
||||
const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`);
|
||||
if (response?.data) {
|
||||
@@ -212,8 +212,8 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
|
||||
return (
|
||||
<AttorneyEngagementLetterDisplay
|
||||
letterData={selectedDocument.sub_document as any}
|
||||
documentId={selectedDocument.id}
|
||||
onSignSuccess={() => fetchDocument(selectedDocument.id)}
|
||||
documentId={selectedDocument.uuid4}
|
||||
onSignSuccess={() => fetchDocument(selectedDocument.uuid4)}
|
||||
/>
|
||||
);
|
||||
} else if (selectedDocument.document_type === 'lender_financing_agreement') {
|
||||
@@ -281,8 +281,8 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
|
||||
<ListItem
|
||||
key={document.id}
|
||||
button
|
||||
selected={selectedDocument?.id === document.id}
|
||||
onClick={() => fetchDocument(document.id)}
|
||||
selected={selectedDocument?.uuid4 === document.uuid4}
|
||||
onClick={() => fetchDocument(document.uuid4)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<ListItemText
|
||||
|
||||
@@ -33,7 +33,9 @@ interface AddPropertyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddProperty: (
|
||||
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated' | 'uuid4' | 'pictures'> & {
|
||||
pictures: string[];
|
||||
},
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -63,7 +65,10 @@ const fetchAutocompleteOptions = async (
|
||||
};
|
||||
|
||||
const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, onAddProperty }) => {
|
||||
const initalValues: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'> = {
|
||||
const initalValues: Omit<
|
||||
PropertiesAPI,
|
||||
'id' | 'owner' | 'created_at' | 'last_updated' | 'uuid4' | 'pictures'
|
||||
> & { pictures: string[] } = {
|
||||
address: '',
|
||||
street: '',
|
||||
city: '',
|
||||
@@ -86,9 +91,17 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
saves: 0,
|
||||
property_status: 'off_market',
|
||||
schools: [],
|
||||
tax_info: {
|
||||
assessed_value: 0,
|
||||
assessment_year: 0,
|
||||
tax_amount: 0,
|
||||
year: 0,
|
||||
},
|
||||
};
|
||||
const [newProperty, setNewProperty] = useState<
|
||||
Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>
|
||||
Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated' | 'uuid4' | 'pictures'> & {
|
||||
pictures: string[];
|
||||
}
|
||||
>({
|
||||
...initalValues,
|
||||
});
|
||||
@@ -218,12 +231,13 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
test_property_search.data.schools.map((item) => {
|
||||
const coordinates = extractLatLon(item.location);
|
||||
return {
|
||||
address: '', // Mock address
|
||||
city: item.city,
|
||||
state: item.state,
|
||||
zip_code: item.zip,
|
||||
latitude: coordinates?.latitude,
|
||||
longitude: coordinates?.longitude,
|
||||
school_type: item.type,
|
||||
school_type: item.type.toLowerCase() as 'public' | 'other',
|
||||
enrollment: item.enrollment,
|
||||
grades: item.grades,
|
||||
name: item.name,
|
||||
@@ -302,6 +316,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
|
||||
(item) => {
|
||||
const coordinates = extractLatLon(item.location);
|
||||
return {
|
||||
address: '', // Mock address
|
||||
city: item.city,
|
||||
state: item.state,
|
||||
zip_code: item.zip,
|
||||
|
||||
@@ -146,39 +146,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
};
|
||||
|
||||
const handleAddProperty = (
|
||||
newPropertyData: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>,
|
||||
newPropertyData: Omit<
|
||||
PropertiesAPI,
|
||||
'id' | 'owner' | 'created_at' | 'last_updated' | 'uuid4' | 'pictures'
|
||||
> & { pictures: string[] },
|
||||
) => {
|
||||
if (user) {
|
||||
const newProperty: PropertyRequestAPI = {
|
||||
...newPropertyData,
|
||||
owner: user.user.id,
|
||||
// created_at: new Date().toISOString().split('T')[0],
|
||||
// last_updated: new Date().toISOString().split('T')[0],
|
||||
created_at: new Date().toISOString().split('T')[0],
|
||||
last_updated: new Date().toISOString().split('T')[0],
|
||||
// @ts-ignore - pictures type mismatch (string[] vs PictureAPI[]) handled by backend or ignored for now
|
||||
open_houses: [],
|
||||
pictures: newPropertyData.pictures as any, // Cast to any to avoid lint error for now
|
||||
};
|
||||
|
||||
newProperty.created_at = new Date().toISOString().split('T')[0];
|
||||
newProperty.last_updated = new Date().toISOString().split('T')[0];
|
||||
|
||||
newProperty.open_houses = [];
|
||||
|
||||
console.log(newProperty);
|
||||
const { data, error } = axiosInstance.post('/properties/', {
|
||||
const postProperty = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.post<PropertiesAPI>('/properties/', {
|
||||
...newProperty,
|
||||
});
|
||||
const updateNewProperty: PropertiesAPI = {
|
||||
...newProperty,
|
||||
...data,
|
||||
owner: user,
|
||||
};
|
||||
setProperties((prev) => [...prev, updateNewProperty]);
|
||||
setMessage({ type: 'success', text: 'Property added successfully!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to add property:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to add property.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
postProperty();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
|
||||
try {
|
||||
const { data } = await axiosInstance.patch<PropertiesAPI>(
|
||||
`/properties/${updatedProperty.id}/`,
|
||||
`/properties/${updatedProperty.uuid4}/`,
|
||||
{
|
||||
...updatedProperty,
|
||||
owner: account.id,
|
||||
@@ -199,10 +209,10 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = async (propertyId: number) => {
|
||||
const handleDeleteProperty = async (propertyId: string) => {
|
||||
try {
|
||||
await axiosInstance.delete(`/properties/${propertyId}/`);
|
||||
setProperties((prev) => prev.filter((item) => item.id !== propertyId));
|
||||
setProperties((prev) => prev.filter((item) => item.uuid4 !== propertyId));
|
||||
setMessage({ type: 'success', text: 'Property has been removed' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Button, Box } from '@mui/material';
|
||||
|
||||
const MLSPublishingCard: React.FC = () => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Publish to MLS
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Publish your property to the MLS. To sites like zillow and redfin.
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button variant="contained" color="primary" fullWidth>
|
||||
Publish Now
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MLSPublishingCard;
|
||||
@@ -35,7 +35,7 @@ interface PropertyDetailCardProps {
|
||||
onSave: (updatedProperty: PropertiesAPI) => void;
|
||||
isPublicPage?: boolean;
|
||||
isOwnerView?: boolean; // True if the current user is the owner, allows editing
|
||||
onDelete?: (propertyId: number) => void;
|
||||
onDelete?: (propertyId: string) => void;
|
||||
}
|
||||
|
||||
const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
@@ -106,7 +106,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
};
|
||||
|
||||
const handleViewPublicListing = () => {
|
||||
navigate(`/property/${property.id}/`);
|
||||
navigate(`/property/${property.uuid4}/`);
|
||||
};
|
||||
|
||||
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -155,14 +155,14 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isOwnerView && !isPublicPage && onDelete) {
|
||||
onDelete(property.id);
|
||||
onDelete(property.uuid4);
|
||||
}
|
||||
};
|
||||
|
||||
const generateDescription = async () => {
|
||||
setIsGernerating(true);
|
||||
|
||||
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`);
|
||||
const response = await axiosInstance.put(`/property-description-generator/${property.uuid4}/`);
|
||||
setEditedProperty((prev) => ({
|
||||
...prev,
|
||||
description: response.data.description,
|
||||
|
||||
@@ -20,9 +20,9 @@ const PropertyListItem: React.FC<PropertyListItemProps> = ({
|
||||
const handleViewDetailsClick = () => {
|
||||
// Navigate to the full detail page for this property
|
||||
if (!isPublic) {
|
||||
navigate(`/property/${property.id}/?search=1`);
|
||||
navigate(`/property/${property.uuid4}/?search=1`);
|
||||
} else {
|
||||
navigate(`/public/${property.id}`);
|
||||
navigate(`/public/${property.uuid4}`);
|
||||
}
|
||||
};
|
||||
const value_price = property.listed_price ? property.listed_price : property.market_value;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface FeatureConfig {
|
||||
|
||||
// UI Features
|
||||
enableFloatingChatButton: boolean;
|
||||
enableMLSPublishing: boolean;
|
||||
|
||||
// API Configuration
|
||||
apiUrl: string;
|
||||
@@ -32,6 +33,7 @@ const getFeatureConfig = (): FeatureConfig => {
|
||||
enableRealEstateAgentRegistration: false,
|
||||
enableRegistration: false,
|
||||
enableFloatingChatButton: false,
|
||||
enableMLSPublishing: false,
|
||||
apiUrl: 'https://backend.ditchtheagent.com/api/',
|
||||
useLiveData: true,
|
||||
};
|
||||
@@ -44,6 +46,7 @@ const getFeatureConfig = (): FeatureConfig => {
|
||||
enableRealEstateAgentRegistration: true,
|
||||
enableRegistration: true,
|
||||
enableFloatingChatButton: true,
|
||||
enableMLSPublishing: true,
|
||||
apiUrl: 'https://beta.backend.ditchtheagent.com/api/',
|
||||
useLiveData: true,
|
||||
};
|
||||
@@ -55,6 +58,7 @@ const getFeatureConfig = (): FeatureConfig => {
|
||||
enableRealEstateAgentRegistration: true,
|
||||
enableRegistration: true,
|
||||
enableFloatingChatButton: true,
|
||||
enableMLSPublishing: true,
|
||||
apiUrl: 'http://127.0.0.1:8010/api/',
|
||||
useLiveData: false,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Button,
|
||||
Avatar,
|
||||
Stack,
|
||||
ListItemButton,
|
||||
} from '@mui/material';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
@@ -123,7 +124,7 @@ const Messages = (): ReactElement => {
|
||||
console.log(fetchedConversations);
|
||||
setConversations(fetchedConversations);
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch (error) { }
|
||||
};
|
||||
fetchConversations();
|
||||
}, []);
|
||||
@@ -239,7 +240,7 @@ const Messages = (): ReactElement => {
|
||||
),
|
||||
);
|
||||
setNewMessageContent(''); // Clear the input field
|
||||
} catch (error) {}
|
||||
} catch (error) { }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -293,7 +294,9 @@ const Messages = (): ReactElement => {
|
||||
.map((conv) => (
|
||||
<ListItem
|
||||
key={conv.id}
|
||||
button
|
||||
disablePadding
|
||||
>
|
||||
<ListItemButton
|
||||
selected={selectedConversationId === conv.id}
|
||||
onClick={() => setSelectedConversationId(conv.id)}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
@@ -326,6 +329,7 @@ const Messages = (): ReactElement => {
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { axiosInstance } from '../../axiosApi';
|
||||
import SchoolCard from 'components/sections/dashboard/Home/Property/SchoolCard';
|
||||
import { AccountContext } from 'contexts/AccountContext';
|
||||
import SellerInformationCard from 'components/sections/dashboard/Home/Property/SellerInformationCard';
|
||||
import MLSPublishingCard from 'components/sections/dashboard/Home/Property/MLSPublishingCard';
|
||||
import { features } from 'config/features';
|
||||
|
||||
const PropertyDetailPage: React.FC = () => {
|
||||
// In a real app, you'd get propertyId from URL params or a global state
|
||||
@@ -87,10 +89,17 @@ const PropertyDetailPage: React.FC = () => {
|
||||
const onStatusChange = async (value: string) => {
|
||||
if (property) {
|
||||
try {
|
||||
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, {
|
||||
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.uuid4}/`, {
|
||||
property_status: value,
|
||||
});
|
||||
setProperty((prev) => (prev ? { ...prev, property_status: value } : null));
|
||||
setProperty((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
property_status: value as 'active' | 'pending' | 'contingent' | 'sold' | 'off_market',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setMessage({ type: 'success', text: `Your listing is now ${value}` });
|
||||
} catch (error: any) {
|
||||
let errorMsg: React.ReactNode = 'There was an error saving your selection. Please try again';
|
||||
@@ -149,7 +158,7 @@ const PropertyDetailPage: React.FC = () => {
|
||||
);
|
||||
} else {
|
||||
const { data } = await axiosInstance.post<SavedPropertiesAPI>(`/saved-properties/`, {
|
||||
property: property.id,
|
||||
property: property.uuid4,
|
||||
user: account.id,
|
||||
});
|
||||
setSavedProperty(data);
|
||||
@@ -174,7 +183,7 @@ const PropertyDetailPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProperty = (propertyId: number) => {
|
||||
const handleDeleteProperty = (propertyId: string) => {
|
||||
console.log('handle delete. IMPLEMENT ME', propertyId);
|
||||
};
|
||||
|
||||
@@ -277,7 +286,7 @@ const PropertyDetailPage: React.FC = () => {
|
||||
isPublicPage={true}
|
||||
onSave={handleSaveProperty}
|
||||
isOwnerView={isOwnerOfProperty}
|
||||
onDelete={() => handleDeleteProperty(property.id)}
|
||||
onDelete={() => handleDeleteProperty(property.uuid4)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -296,6 +305,11 @@ const PropertyDetailPage: React.FC = () => {
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
|
||||
</Grid>
|
||||
{isOwnerOfProperty && features.enableMLSPublishing && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<MLSPublishingCard />
|
||||
</Grid>
|
||||
)}
|
||||
{!isOwnerOfProperty && (
|
||||
<>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
|
||||
@@ -72,9 +72,9 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
text: item.description,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
user: -1, // Placeholder
|
||||
user_first_name: 'System',
|
||||
user_last_name: 'Description'
|
||||
user: item.user_email,
|
||||
user_first_name: item.user_first_name,
|
||||
user_last_name: item.user_last_name,
|
||||
}]
|
||||
}));
|
||||
|
||||
@@ -129,7 +129,7 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
result = result.filter(item =>
|
||||
item.title.toLowerCase().includes(lowerSearch) ||
|
||||
item.description.toLowerCase().includes(lowerSearch)
|
||||
// || item.user?.email?.toLowerCase().includes(lowerSearch) // If user object existed
|
||||
|| item.user_email?.toLowerCase().includes(lowerSearch) // If user object existed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,6 +215,26 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCase = async () => {
|
||||
if (!selectedSupportCaseId) return;
|
||||
|
||||
try {
|
||||
const { data }: AxiosResponse<SupportCaseApi> = await axiosInstance.patch(
|
||||
`/support/cases/${selectedSupportCaseId}/`,
|
||||
{ status: 'closed' }
|
||||
);
|
||||
|
||||
setSupportCase(prev => prev ? { ...prev, status: data.status, updated_at: data.updated_at } : null);
|
||||
setSupportCases(prevCases =>
|
||||
prevCases.map(c =>
|
||||
c.id === selectedSupportCaseId ? { ...c, status: data.status, updated_at: data.updated_at } : c
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to close case", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
@@ -233,7 +253,8 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{ py: 4, height: '90vh', width: 'fill', display: 'flex', flexDirection: 'column' }}
|
||||
maxWidth={false}
|
||||
sx={{ py: 4, height: '90vh', width: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
@@ -242,7 +263,9 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
<Grid container sx={{ height: '100%', width: '100%' }}>
|
||||
{/* Left Panel: List & Filters */}
|
||||
<Grid
|
||||
size={{ xs: 12, md: 4 }}
|
||||
item
|
||||
xs={12}
|
||||
md={4}
|
||||
sx={{
|
||||
borderRight: { md: '1px solid', borderColor: 'grey.200' },
|
||||
display: 'flex',
|
||||
@@ -337,6 +360,7 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
@@ -354,12 +378,21 @@ const SupportAgentDashboard: React.FC = () => {
|
||||
borderColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{supportCase.title} | {supportCase.category}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleCloseCase}
|
||||
disabled={supportCase.status === 'closed'}
|
||||
>
|
||||
{supportCase.status === 'closed' ? 'Closed' : 'Close Case'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import ForgotPassword from './ForgotPassword';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
|
||||
// Mock axiosInstance
|
||||
vi.mock('../../axiosApi.js', () => ({
|
||||
axiosInstance: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ForgotPassword', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<ForgotPassword />);
|
||||
expect(screen.getByText('Forgot Password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Send Password Reset Link/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates email input', () => {
|
||||
render(<ForgotPassword />);
|
||||
const emailInput = screen.getByLabelText('Email');
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
});
|
||||
|
||||
it('calls API and shows success message on valid submission', async () => {
|
||||
(axiosInstance.post as any).mockResolvedValue({ status: 200 });
|
||||
render(<ForgotPassword />);
|
||||
|
||||
const emailInput = screen.getByLabelText('Email');
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Send Password Reset Link/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(screen.getByText('Sending...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosInstance.post).toHaveBeenCalledWith('password-reset/', { email: 'test@example.com' });
|
||||
expect(screen.getByText('Password reset link has been sent to your email.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on API failure', async () => {
|
||||
const errorMessage = 'User not found';
|
||||
(axiosInstance.post as any).mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
detail: errorMessage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ForgotPassword />);
|
||||
|
||||
const emailInput = screen.getByLabelText('Email');
|
||||
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Send Password Reset Link/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControl,
|
||||
InputAdornment,
|
||||
@@ -10,12 +11,36 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import Image from 'components/base/Image';
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import forgotPassword from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setLoading(true);
|
||||
setSuccessMessage(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await axiosInstance.post('password-reset/', { email });
|
||||
setSuccessMessage('Password reset link has been sent to your email.');
|
||||
} catch (error: any) {
|
||||
setErrorMessage(
|
||||
error.response?.data?.email?.[0] ||
|
||||
error.response?.data?.detail ||
|
||||
'An error occurred. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -30,14 +55,18 @@ const ForgotPassword = () => {
|
||||
</Link>
|
||||
<Stack alignItems="center" gap={6.5} width={330} mx="auto">
|
||||
<Typography variant="h3">Forgot Password</Typography>
|
||||
{successMessage && <Alert severity="success">{successMessage}</Alert>}
|
||||
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="new-password">
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -47,8 +76,13 @@ const ForgotPassword = () => {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="contained" fullWidth>
|
||||
Send Password Reset Link
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleResetPassword}
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Password Reset Link'}
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Back to{' '}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { axiosInstance } from '../../axiosApi.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { AuthContext } from 'contexts/AuthContext.js';
|
||||
import paths from 'routes/paths';
|
||||
|
||||
type loginValues = {
|
||||
email: string;
|
||||
@@ -50,7 +51,15 @@ const Login = (): ReactElement => {
|
||||
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
const hasErrors = Object.keys(error.response.data).length > 0;
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 401 &&
|
||||
error.response.data.detail === 'No active account found with the given credentials'
|
||||
) {
|
||||
navigate(paths.verifyAccount, { state: { email } });
|
||||
return;
|
||||
}
|
||||
const hasErrors = error.response && Object.keys(error.response.data).length > 0;
|
||||
if (hasErrors) {
|
||||
setErrorMessage(error.response.data);
|
||||
} else {
|
||||
|
||||
157
ditch-the-agent/src/pages/authentication/ResetPassword.test.tsx
Normal file
157
ditch-the-agent/src/pages/authentication/ResetPassword.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import ResetPassword from './ResetPassword';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
// Mock axiosInstance
|
||||
vi.mock('../../axiosApi.js', () => ({
|
||||
axiosInstance: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useSearchParams
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => [{ get: mockGet }],
|
||||
};
|
||||
});
|
||||
|
||||
describe('ResetPassword', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockImplementation((key) => {
|
||||
if (key === 'email') return 'test@example.com';
|
||||
if (key === 'code') return '123456';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ResetPassword />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('heading', { name: /Reset Password/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Reset Code')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Reset Password/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates inputs', () => {
|
||||
renderComponent();
|
||||
const emailInput = screen.getByLabelText('Email');
|
||||
const codeInput = screen.getByLabelText('Reset Code');
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const confirmInput = screen.getByLabelText('Confirm Password');
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
||||
fireEvent.change(codeInput, { target: { value: '654321' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(emailInput).toHaveValue('new@example.com');
|
||||
expect(codeInput).toHaveValue('654321');
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
expect(confirmInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('shows error if passwords do not match', async () => {
|
||||
renderComponent();
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const confirmInput = screen.getByLabelText('Confirm Password');
|
||||
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmInput, { target: { value: 'password456' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText("Passwords don't match")).toBeInTheDocument();
|
||||
expect(axiosInstance.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls API and shows success message on valid submission', async () => {
|
||||
(axiosInstance.post as any).mockResolvedValue({ status: 200 });
|
||||
renderComponent();
|
||||
|
||||
// Email and Code are pre-filled from mock
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const confirmInput = screen.getByLabelText('Confirm Password');
|
||||
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(screen.getByText('Resetting...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosInstance.post).toHaveBeenCalledWith('password-reset/confirm/', {
|
||||
email: 'test@example.com',
|
||||
code: '123456',
|
||||
new_password: 'password123',
|
||||
new_password2: 'password123',
|
||||
});
|
||||
expect(screen.getByText('Reset Successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on API failure', async () => {
|
||||
const errorMessage = 'Invalid token';
|
||||
(axiosInstance.post as any).mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
detail: errorMessage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const confirmInput = screen.getByLabelText('Confirm Password');
|
||||
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error if email or code is missing', async () => {
|
||||
mockGet.mockReturnValue(null); // No URL params
|
||||
renderComponent();
|
||||
|
||||
// Inputs should be empty
|
||||
const emailInput = screen.getByLabelText('Email');
|
||||
const codeInput = screen.getByLabelText('Reset Code');
|
||||
expect(emailInput).toHaveValue('');
|
||||
expect(codeInput).toHaveValue('');
|
||||
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const confirmInput = screen.getByLabelText('Confirm Password');
|
||||
const submitButton = screen.getByRole('button', { name: /Reset Password/i });
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText('Please provide both email and reset code.')).toBeInTheDocument();
|
||||
expect(axiosInstance.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, Suspense, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
@@ -17,30 +18,55 @@ import passwordUpdated from 'assets/authentication-banners/password-updated.png'
|
||||
import successTick from 'assets/authentication-banners/successTick.png';
|
||||
import Image from 'components/base/Image';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
|
||||
const ResetPassword = (): ReactElement => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [email, setEmail] = useState(searchParams.get('email') || '');
|
||||
const [code, setCode] = useState(searchParams.get('code') || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [resetSuccessful, setResetSuccessful] = useState(false);
|
||||
|
||||
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
|
||||
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
|
||||
const [resetSuccessful, setResetSuccessful] = useState(false);
|
||||
|
||||
const handleResetPassword = () => {
|
||||
const passwordField: HTMLInputElement = document.getElementById(
|
||||
'new-password',
|
||||
) as HTMLInputElement;
|
||||
const confirmPasswordField: HTMLInputElement = document.getElementById(
|
||||
'confirm-password',
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (passwordField.value !== confirmPasswordField.value) {
|
||||
alert("Passwords don't match");
|
||||
const handleResetPassword = async () => {
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email || !code) {
|
||||
setErrorMessage('Please provide both email and reset code.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await axiosInstance.post('password-reset/confirm/', {
|
||||
email,
|
||||
code,
|
||||
new_password: password,
|
||||
new_password2: confirmPassword,
|
||||
});
|
||||
setResetSuccessful(true);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.non_field_errors?.[0] ||
|
||||
'An error occurred. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -58,6 +84,45 @@ const ResetPassword = (): ReactElement => {
|
||||
{!resetSuccessful ? (
|
||||
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
||||
<Typography variant="h3">Reset Password</Typography>
|
||||
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="code">
|
||||
Reset Code
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter reset code"
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-lock" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="new-password">
|
||||
Password
|
||||
@@ -67,6 +132,7 @@ const ResetPassword = (): ReactElement => {
|
||||
placeholder="Enter new password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
id="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
@@ -89,17 +155,18 @@ const ResetPassword = (): ReactElement => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/*<PasswordStrengthChecker password={password} />*/}
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel shrink htmlFor="confirm-password">
|
||||
Password
|
||||
Confirm Password
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Confirm password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirm-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -122,8 +189,13 @@ const ResetPassword = (): ReactElement => {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="contained" fullWidth onClick={handleResetPassword}>
|
||||
Reset Password
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleResetPassword}
|
||||
disabled={loading || !password || !confirmPassword}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Back to{' '}
|
||||
@@ -140,7 +212,7 @@ const ResetPassword = (): ReactElement => {
|
||||
<Stack alignItems="center" gap={3.75} width={330} mx="auto">
|
||||
<Image src={successTick} />
|
||||
<Typography variant="h3">Reset Successfully</Typography>
|
||||
<Typography variant="body1" textAlign="center" color="text.secndary">
|
||||
<Typography variant="body1" textAlign="center" color="text.secondary">
|
||||
Your Ditch the Agent log in password has been updated successfully
|
||||
</Typography>
|
||||
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">
|
||||
|
||||
203
ditch-the-agent/src/pages/authentication/VerifyAccount.tsx
Normal file
203
ditch-the-agent/src/pages/authentication/VerifyAccount.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { ReactElement, Suspense, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControl,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Link,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import loginBanner from 'assets/authentication-banners/green.png';
|
||||
import IconifyIcon from 'components/base/IconifyIcon';
|
||||
import logo from 'assets/logo/favicon-logo.png';
|
||||
import Image from 'components/base/Image';
|
||||
import { axiosInstance } from '../../axiosApi.js';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Form, Formik } from 'formik';
|
||||
import paths from 'routes/paths';
|
||||
|
||||
type verifyValues = {
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const VerifyAccount = (): ReactElement => {
|
||||
const [errorMessage, setErrorMessage] = useState<any | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const initialEmail = location.state?.email || '';
|
||||
|
||||
const handleVerify = async ({ email, code }: verifyValues): Promise<void> => {
|
||||
try {
|
||||
await axiosInstance.post('/check-passcode/', {
|
||||
email: email,
|
||||
code: code,
|
||||
});
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const hasErrors = error.response && Object.keys(error.response.data).length > 0;
|
||||
if (hasErrors) {
|
||||
setErrorMessage(error.response.data);
|
||||
} else {
|
||||
setErrorMessage('An unexpected error occurred.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
bgcolor="background.paper"
|
||||
boxShadow={(theme) => theme.shadows[3]}
|
||||
height={560}
|
||||
width={{ md: 960 }}
|
||||
>
|
||||
<Stack width={{ md: 0.5 }} m={2.5} gap={10}>
|
||||
<Link href="/" height="fit-content">
|
||||
<Image src={logo} width={82.6} />
|
||||
</Link>
|
||||
<Stack alignItems="center" gap={2.5} width={330} mx="auto">
|
||||
<Typography variant="h3">Verify Account</Typography>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: initialEmail,
|
||||
code: '',
|
||||
}}
|
||||
onSubmit={handleVerify}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ setFieldValue, values }) => (
|
||||
<Form>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
{successMessage ? (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<Typography>{successMessage}</Typography>
|
||||
</Alert>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<Alert severity="error">
|
||||
{errorMessage.detail ? (
|
||||
<Typography>{errorMessage.detail}</Typography>
|
||||
) : (
|
||||
<ul>
|
||||
{Object.entries(errorMessage).map(([fieldName, errorMessages]) => (
|
||||
<li key={fieldName}>
|
||||
<strong>{fieldName}</strong>
|
||||
{Array.isArray(errorMessages) ? (
|
||||
<ul>
|
||||
{errorMessages.map((message, index) => (
|
||||
<li key={`${fieldName}-${index}`}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span>: {String(errorMessages)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Alert>
|
||||
) : null}
|
||||
<InputLabel shrink htmlFor="email">
|
||||
Email
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter your email"
|
||||
id="email"
|
||||
value={values.email}
|
||||
onChange={(event) => setFieldValue('email', event.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-email" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl variant="standard" fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel shrink htmlFor="code">
|
||||
Verification Code
|
||||
</InputLabel>
|
||||
<TextField
|
||||
variant="filled"
|
||||
placeholder="Enter verification code"
|
||||
id="code"
|
||||
value={values.code}
|
||||
onChange={(event) => setFieldValue('code', event.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconifyIcon icon="ic:baseline-lock" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button variant="contained" type={'submit'} fullWidth sx={{ mt: 3 }}>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
onClick={async () => {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
if (!values.email) {
|
||||
setErrorMessage({ detail: 'Email is required to resend code.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axiosInstance.post('/resend-registration-email/', {
|
||||
email: values.email,
|
||||
});
|
||||
setSuccessMessage('Registration email sent.');
|
||||
} catch (error) {
|
||||
setErrorMessage({ detail: 'There was an error sending the verification link.' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Resend Verification Code
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Back to{' '}
|
||||
<Link
|
||||
href={paths.login}
|
||||
underline="hover"
|
||||
fontSize={(theme) => theme.typography.body1.fontSize}
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton variant="rectangular" height={1} width={1} sx={{ bgcolor: 'primary.main' }} />
|
||||
}
|
||||
>
|
||||
<Image
|
||||
alt="Login banner"
|
||||
src={loginBanner}
|
||||
sx={{
|
||||
width: 0.5,
|
||||
display: { xs: 'none', md: 'block' },
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyAccount;
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
home: `/${rootPaths.homeRoot}`,
|
||||
dashboard: `/${rootPaths.dashboardRoot}`,
|
||||
login: `/${rootPaths.authRoot}/login`,
|
||||
verifyAccount: `/${rootPaths.authRoot}/verify-account`,
|
||||
signup: `/${rootPaths.authRoot}/sign-up`,
|
||||
resetPassword: `/${rootPaths.authRoot}/reset-password`,
|
||||
forgotPassword: `/${rootPaths.authRoot}/forgot-password`,
|
||||
|
||||
@@ -76,6 +76,7 @@ const Error404 = lazy(async () => {
|
||||
});
|
||||
|
||||
const Login = lazy(async () => import('pages/authentication/Login'));
|
||||
const VerifyAccount = lazy(async () => import('pages/authentication/VerifyAccount'));
|
||||
const SignUp = lazy(async () => import('pages/authentication/SignUp'));
|
||||
|
||||
const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword'));
|
||||
@@ -179,6 +180,10 @@ const routes: RouteObject[] = [
|
||||
path: paths.login,
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: paths.verifyAccount,
|
||||
element: <VerifyAccount />,
|
||||
},
|
||||
{
|
||||
path: paths.signup,
|
||||
element: <SignUp />,
|
||||
|
||||
1
ditch-the-agent/src/setupTests.ts
Normal file
1
ditch-the-agent/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -94,6 +94,7 @@ export interface MessagesAPI {
|
||||
|
||||
export interface UserAPI {
|
||||
id: number;
|
||||
uuid4: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
@@ -268,6 +269,7 @@ export interface WalkScoreAPI {
|
||||
|
||||
export interface PropertiesAPI {
|
||||
id: number;
|
||||
uuid4: string;
|
||||
owner: PropertyOwnerAPI;
|
||||
address: string; // full address
|
||||
street: string;
|
||||
@@ -354,6 +356,7 @@ export interface LenderFinancingAgreementData {
|
||||
|
||||
export interface DocumentAPI {
|
||||
id: number;
|
||||
uuid4: string;
|
||||
property: number;
|
||||
file: string;
|
||||
document_type: string;
|
||||
@@ -370,7 +373,7 @@ export interface DocumentAPI {
|
||||
| LenderFinancingAgreementData;
|
||||
}
|
||||
|
||||
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> {
|
||||
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id' | 'uuid4'> {
|
||||
owner: number;
|
||||
}
|
||||
|
||||
@@ -770,7 +773,7 @@ export interface FaqApi {
|
||||
export interface SupportMessageApi {
|
||||
id: number;
|
||||
text: string;
|
||||
user: id;
|
||||
user: number;
|
||||
user_first_name: string;
|
||||
user_last_name: string;
|
||||
created_at: string;
|
||||
@@ -786,5 +789,8 @@ export interface SupportCaseApi {
|
||||
messages: SupportMessageApi[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_email: string;
|
||||
user_first_name: string;
|
||||
user_last_name: string;
|
||||
}
|
||||
// Walk Score API Type Definitions
|
||||
|
||||
@@ -16,4 +16,9 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
},
|
||||
base: '/',
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setupTests.ts',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user