Compare commits

...

3 Commits

Author SHA1 Message Date
d4514c998a closes #14
closes #15
2025-12-15 10:38:31 -06:00
a6f267492d ensured password reset and account verification works as well as support dashboard 2025-12-13 06:50:29 -06:00
173f6ccf6d closes #9 2025-12-11 16:13:22 -06:00
25 changed files with 3165 additions and 212 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"predeploy": "vite build && cp ./dist/index.html ./dist/404.html", "predeploy": "vite build && cp ./dist/index.html ./dist/404.html",
"deploy": "gh-pages -d dist" "deploy": "gh-pages -d dist",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
@@ -43,6 +44,9 @@
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^4.1.1", "@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": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
@@ -52,8 +56,10 @@
"eslint-plugin-prettier": "^5.5.3", "eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"jsdom": "^24.0.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.3.1"
} }
} }

View File

@@ -1,6 +1,6 @@
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { ReactElement, useContext, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI, UserAPI } from 'types'; import { DocumentAPI, PropertiesAPI, SavedPropertiesAPI } 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 {
@@ -15,7 +15,6 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { drawerWidth } from 'layouts/main-layout'; import { drawerWidth } from 'layouts/main-layout';
import { ProperyInfoCards } from '../Property/PropertyInfo';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import HouseIcon from '@mui/icons-material/House'; import HouseIcon from '@mui/icons-material/House';
@@ -109,7 +108,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => { const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
try { try {
const { data } = await axiosInstance.patch<PropertiesAPI>( const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`, `/properties/${updatedProperty.uuid4}/`,
{ {
...updatedProperty, ...updatedProperty,
owner: account.id, owner: account.id,
@@ -130,10 +129,10 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
} }
}; };
const handleDeleteProperty = async (propertyId: number) => { const handleDeleteProperty = async (propertyId: string) => {
try { try {
await axiosInstance.delete(`/properties/${propertyId}/`); 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' }); setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000); setTimeout(() => setMessage(null), 3000);
} catch (error) { } catch (error) {
@@ -203,13 +202,13 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
> >
{/* quick states */} {/* quick states */}
{!account.profile_created && ( {!account.profile_created && (
<Grid xs={12} key="profile-setup"> <Grid item xs={12} key="profile-setup">
<Alert severity="warning" sx={{ mb: 2 }}> <Alert severity="warning" sx={{ mb: 2 }}>
Please set up your <a href="/profile">profile</a> Please set up your <a href="/profile">profile</a>
</Alert> </Alert>
</Grid> </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')}> <Card sx={{ display: 'flex' }} onClick={() => navigate('/profile')}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center"> <Stack direction="column" alignItems="center">
@@ -220,7 +219,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </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' }}> <Card sx={{ display: 'flex' }}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center"> <Stack direction="column" alignItems="center">
@@ -231,7 +230,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </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' }}> <Card sx={{ display: 'flex' }}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center"> <Stack direction="column" alignItems="center">
@@ -242,7 +241,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </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')}> <Card sx={{ display: 'flex' }} onClick={() => navigate('/offers')}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center"> <Stack direction="column" alignItems="center">
@@ -253,7 +252,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </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')}> <Card sx={{ display: 'flex' }} onClick={() => navigate('/bids')}>
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack direction="column" alignItems="center"> <Stack direction="column" alignItems="center">
@@ -266,7 +265,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</Grid> </Grid>
{account.tier === 'basic' && ( {account.tier === 'basic' && (
<Grid xs={12} md={4}> <Grid item xs={12} md={4}>
<Card> <Card>
<Stack direction="column"> <Stack direction="column">
<Typography variant="h4">Upgrade your account</Typography> <Typography variant="h4">Upgrade your account</Typography>
@@ -292,12 +291,12 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</Alert> </Alert>
)} )}
{properties.length > 0 && ( {properties.length > 0 && (
<Grid xs={12}> <Grid item xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
</Grid> </Grid>
)} )}
{properties.map((item) => ( {properties.map((item) => (
<Grid xs={12} key={item.id}> <Grid item xs={12} key={item.id}>
<PropertyDetailCard <PropertyDetailCard
property={item} property={item}
isPublicPage={false} isPublicPage={false}
@@ -308,11 +307,11 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
{/* <ProperyInfoCards property={item} /> */} {/* <ProperyInfoCards property={item} /> */}
</Grid> </Grid>
))} ))}
<Grid xs={12}> <Grid item xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
</Grid> </Grid>
<Grid xs={12} md={documentsCardLength}> <Grid item 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>
@@ -329,7 +328,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</Card> </Card>
</Grid> </Grid>
<Grid xs={12} md={savedPropertiesCardLength}> <Grid item xs={12} md={savedPropertiesCardLength}>
<Card> <Card>
<Stack direction="column"> <Stack direction="column">
<Stack direction="column"> <Stack direction="column">
@@ -343,7 +342,7 @@ const PropertyOwnerDashboard = ({ account }: DashboardProps): ReactElement => {
</Stack> </Stack>
</Card> </Card>
</Grid> </Grid>
<Grid xs={12} md={12}> <Grid item xs={12} md={12}>
{account.tier === 'premium' ? ( {account.tier === 'premium' ? (
<Card> <Card>
<Stack direction="column"> <Stack direction="column">

View File

@@ -14,7 +14,7 @@ import { axiosInstance } from '../../../../../axiosApi';
interface AttorneyEngagementLetterDisplayProps { interface AttorneyEngagementLetterDisplayProps {
letterData: AttorneyEngagementLetterData; letterData: AttorneyEngagementLetterData;
documentId: number; documentId: string;
onSignSuccess?: () => void; onSignSuccess?: () => void;
} }

View File

@@ -107,7 +107,7 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
useEffect(() => { useEffect(() => {
const selectedDocumentId = searchParams.get('selectedDocument'); const selectedDocumentId = searchParams.get('selectedDocument');
if (selectedDocumentId) { if (selectedDocumentId) {
fetchDocument(parseInt(selectedDocumentId, 10)); fetchDocument(selectedDocumentId);
} }
}, [searchParams]); }, [searchParams]);
@@ -145,7 +145,7 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
console.log(documents); console.log(documents);
const fetchDocument = async (documentId: number) => { const fetchDocument = async (documentId: string) => {
try { try {
const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`); const response = await axiosInstance.get(`/documents/retrieve/?docId=${documentId}`);
if (response?.data) { if (response?.data) {
@@ -212,8 +212,8 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
return ( return (
<AttorneyEngagementLetterDisplay <AttorneyEngagementLetterDisplay
letterData={selectedDocument.sub_document as any} letterData={selectedDocument.sub_document as any}
documentId={selectedDocument.id} documentId={selectedDocument.uuid4}
onSignSuccess={() => fetchDocument(selectedDocument.id)} onSignSuccess={() => fetchDocument(selectedDocument.uuid4)}
/> />
); );
} else if (selectedDocument.document_type === 'lender_financing_agreement') { } else if (selectedDocument.document_type === 'lender_financing_agreement') {
@@ -281,8 +281,8 @@ const DocumentManager: React.FC<DocumentManagerProps> = ({ }) => {
<ListItem <ListItem
key={document.id} key={document.id}
button button
selected={selectedDocument?.id === document.id} selected={selectedDocument?.uuid4 === document.uuid4}
onClick={() => fetchDocument(document.id)} onClick={() => fetchDocument(document.uuid4)}
sx={{ py: 1.5, px: 2 }} sx={{ py: 1.5, px: 2 }}
> >
<ListItemText <ListItemText

View File

@@ -33,7 +33,9 @@ interface AddPropertyDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onAddProperty: ( onAddProperty: (
newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated'>, newProperty: Omit<PropertiesAPI, 'id' | 'owner' | 'created_at' | 'last_updated' | 'uuid4' | 'pictures'> & {
pictures: string[];
},
) => void; ) => void;
} }
@@ -63,7 +65,10 @@ const fetchAutocompleteOptions = async (
}; };
const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, onAddProperty }) => { 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: '', address: '',
street: '', street: '',
city: '', city: '',
@@ -86,9 +91,17 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
saves: 0, saves: 0,
property_status: 'off_market', property_status: 'off_market',
schools: [], schools: [],
tax_info: {
assessed_value: 0,
assessment_year: 0,
tax_amount: 0,
year: 0,
},
}; };
const [newProperty, setNewProperty] = useState< 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, ...initalValues,
}); });
@@ -218,12 +231,13 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
test_property_search.data.schools.map((item) => { test_property_search.data.schools.map((item) => {
const coordinates = extractLatLon(item.location); const coordinates = extractLatLon(item.location);
return { return {
address: '', // Mock address
city: item.city, city: item.city,
state: item.state, state: item.state,
zip_code: item.zip, zip_code: item.zip,
latitude: coordinates?.latitude, latitude: coordinates?.latitude,
longitude: coordinates?.longitude, longitude: coordinates?.longitude,
school_type: item.type, school_type: item.type.toLowerCase() as 'public' | 'other',
enrollment: item.enrollment, enrollment: item.enrollment,
grades: item.grades, grades: item.grades,
name: item.name, name: item.name,
@@ -302,6 +316,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
(item) => { (item) => {
const coordinates = extractLatLon(item.location); const coordinates = extractLatLon(item.location);
return { return {
address: '', // Mock address
city: item.city, city: item.city,
state: item.state, state: item.state,
zip_code: item.zip, zip_code: item.zip,

View File

@@ -146,39 +146,49 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
}; };
const handleAddProperty = ( 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) { if (user) {
const newProperty: PropertyRequestAPI = { const newProperty: PropertyRequestAPI = {
...newPropertyData, ...newPropertyData,
owner: user.user.id, owner: user.user.id,
// created_at: new Date().toISOString().split('T')[0], created_at: new Date().toISOString().split('T')[0],
// last_updated: 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); console.log(newProperty);
const { data, error } = axiosInstance.post('/properties/', { const postProperty = async () => {
...newProperty, try {
}); const { data } = await axiosInstance.post<PropertiesAPI>('/properties/', {
const updateNewProperty: PropertiesAPI = { ...newProperty,
...newProperty, });
owner: user, const updateNewProperty: PropertiesAPI = {
...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);
}
}; };
setProperties((prev) => [...prev, updateNewProperty]); postProperty();
setMessage({ type: 'success', text: 'Property added successfully!' });
setTimeout(() => setMessage(null), 3000);
} }
}; };
const handleSaveProperty = async (updatedProperty: PropertiesAPI) => { const handleSaveProperty = async (updatedProperty: PropertiesAPI) => {
try { try {
const { data } = await axiosInstance.patch<PropertiesAPI>( const { data } = await axiosInstance.patch<PropertiesAPI>(
`/properties/${updatedProperty.id}/`, `/properties/${updatedProperty.uuid4}/`,
{ {
...updatedProperty, ...updatedProperty,
owner: account.id, owner: account.id,
@@ -199,10 +209,10 @@ const PropertyOwnerProfile = ({ account }: ProfileProps): ReactElement => {
} }
}; };
const handleDeleteProperty = async (propertyId: number) => { const handleDeleteProperty = async (propertyId: string) => {
try { try {
await axiosInstance.delete(`/properties/${propertyId}/`); 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' }); setMessage({ type: 'success', text: 'Property has been removed' });
setTimeout(() => setMessage(null), 3000); setTimeout(() => setMessage(null), 3000);
} catch (error) { } catch (error) {

View File

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

View File

@@ -35,7 +35,7 @@ interface PropertyDetailCardProps {
onSave: (updatedProperty: PropertiesAPI) => void; onSave: (updatedProperty: PropertiesAPI) => void;
isPublicPage?: boolean; isPublicPage?: boolean;
isOwnerView?: boolean; // True if the current user is the owner, allows editing 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> = ({ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
@@ -106,7 +106,7 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
}; };
const handleViewPublicListing = () => { const handleViewPublicListing = () => {
navigate(`/property/${property.id}/`); navigate(`/property/${property.uuid4}/`);
}; };
const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePictureUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -155,14 +155,14 @@ const PropertyDetailCard: React.FC<PropertyDetailCardProps> = ({
const handleDelete = () => { const handleDelete = () => {
if (isOwnerView && !isPublicPage && onDelete) { if (isOwnerView && !isPublicPage && onDelete) {
onDelete(property.id); onDelete(property.uuid4);
} }
}; };
const generateDescription = async () => { const generateDescription = async () => {
setIsGernerating(true); setIsGernerating(true);
const response = await axiosInstance.put(`/property-description-generator/${property.id}/`); const response = await axiosInstance.put(`/property-description-generator/${property.uuid4}/`);
setEditedProperty((prev) => ({ setEditedProperty((prev) => ({
...prev, ...prev,
description: response.data.description, description: response.data.description,

View File

@@ -20,9 +20,9 @@ const PropertyListItem: React.FC<PropertyListItemProps> = ({
const handleViewDetailsClick = () => { const handleViewDetailsClick = () => {
// Navigate to the full detail page for this property // Navigate to the full detail page for this property
if (!isPublic) { if (!isPublic) {
navigate(`/property/${property.id}/?search=1`); navigate(`/property/${property.uuid4}/?search=1`);
} else { } else {
navigate(`/public/${property.id}`); navigate(`/public/${property.uuid4}`);
} }
}; };
const value_price = property.listed_price ? property.listed_price : property.market_value; const value_price = property.listed_price ? property.listed_price : property.market_value;

View File

@@ -11,6 +11,7 @@ interface FeatureConfig {
// UI Features // UI Features
enableFloatingChatButton: boolean; enableFloatingChatButton: boolean;
enableMLSPublishing: boolean;
// API Configuration // API Configuration
apiUrl: string; apiUrl: string;
@@ -32,6 +33,7 @@ const getFeatureConfig = (): FeatureConfig => {
enableRealEstateAgentRegistration: false, enableRealEstateAgentRegistration: false,
enableRegistration: false, enableRegistration: false,
enableFloatingChatButton: false, enableFloatingChatButton: false,
enableMLSPublishing: false,
apiUrl: 'https://backend.ditchtheagent.com/api/', apiUrl: 'https://backend.ditchtheagent.com/api/',
useLiveData: true, useLiveData: true,
}; };
@@ -44,6 +46,7 @@ const getFeatureConfig = (): FeatureConfig => {
enableRealEstateAgentRegistration: true, enableRealEstateAgentRegistration: true,
enableRegistration: true, enableRegistration: true,
enableFloatingChatButton: true, enableFloatingChatButton: true,
enableMLSPublishing: true,
apiUrl: 'https://beta.backend.ditchtheagent.com/api/', apiUrl: 'https://beta.backend.ditchtheagent.com/api/',
useLiveData: true, useLiveData: true,
}; };
@@ -55,6 +58,7 @@ const getFeatureConfig = (): FeatureConfig => {
enableRealEstateAgentRegistration: true, enableRealEstateAgentRegistration: true,
enableRegistration: true, enableRegistration: true,
enableFloatingChatButton: true, enableFloatingChatButton: true,
enableMLSPublishing: true,
apiUrl: 'http://127.0.0.1:8010/api/', apiUrl: 'http://127.0.0.1:8010/api/',
useLiveData: false, useLiveData: false,
}; };

View File

@@ -35,6 +35,7 @@ import {
Button, Button,
Avatar, Avatar,
Stack, Stack,
ListItemButton,
} from '@mui/material'; } from '@mui/material';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
@@ -123,7 +124,7 @@ const Messages = (): ReactElement => {
console.log(fetchedConversations); console.log(fetchedConversations);
setConversations(fetchedConversations); setConversations(fetchedConversations);
} }
} catch (error) {} } catch (error) { }
}; };
fetchConversations(); fetchConversations();
}, []); }, []);
@@ -230,16 +231,16 @@ const Messages = (): ReactElement => {
prevConversations.map((conv) => prevConversations.map((conv) =>
conv.id === selectedConversationId conv.id === selectedConversationId
? { ? {
...conv, ...conv,
messages: [...conv.messages, newMessage], messages: [...conv.messages, newMessage],
lastMessageSnippet: newMessage.content, lastMessageSnippet: newMessage.content,
lastMessageTimestamp: newMessage.timestamp, lastMessageTimestamp: newMessage.timestamp,
} }
: conv, : conv,
), ),
); );
setNewMessageContent(''); // Clear the input field setNewMessageContent(''); // Clear the input field
} catch (error) {} } catch (error) { }
}; };
return ( return (
@@ -293,39 +294,42 @@ const Messages = (): ReactElement => {
.map((conv) => ( .map((conv) => (
<ListItem <ListItem
key={conv.id} key={conv.id}
button disablePadding
selected={selectedConversationId === conv.id}
onClick={() => setSelectedConversationId(conv.id)}
sx={{ py: 1.5, px: 2 }}
> >
<ListItemText <ListItemButton
primary={ selected={selectedConversationId === conv.id}
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}> onClick={() => setSelectedConversationId(conv.id)}
{conv.withName} sx={{ py: 1.5, px: 2 }}
</Typography> >
} <ListItemText
secondary={ primary={
<Box <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
sx={{ {conv.withName}
display: 'flex', </Typography>
justifyContent: 'space-between', }
alignItems: 'center', secondary={
}} <Box
> sx={{
<Typography display: 'flex',
variant="body2" justifyContent: 'space-between',
color="text.secondary" alignItems: 'center',
noWrap }}
sx={{ flexGrow: 1, pr: 1 }}
> >
{conv.lastMessageSnippet} <Typography
</Typography> variant="body2"
<Typography variant="caption" color="text.disabled"> color="text.secondary"
{formatTimestamp(conv.lastMessageTimestamp)} noWrap
</Typography> sx={{ flexGrow: 1, pr: 1 }}
</Box> >
} {conv.lastMessageSnippet}
/> </Typography>
<Typography variant="caption" color="text.disabled">
{formatTimestamp(conv.lastMessageTimestamp)}
</Typography>
</Box>
}
/>
</ListItemButton>
</ListItem> </ListItem>
)) ))
)} )}

View File

@@ -25,6 +25,8 @@ 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'; 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 = () => { 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
@@ -87,10 +89,17 @@ const PropertyDetailPage: React.FC = () => {
const onStatusChange = async (value: string) => { const onStatusChange = async (value: string) => {
if (property) { if (property) {
try { try {
await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.id}/`, { await axiosInstance.patch<SavedPropertiesAPI>(`/properties/${property.uuid4}/`, {
property_status: value, 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}` }); setMessage({ type: 'success', text: `Your listing is now ${value}` });
} catch (error: any) { } catch (error: any) {
let errorMsg: React.ReactNode = 'There was an error saving your selection. Please try again'; let errorMsg: React.ReactNode = 'There was an error saving your selection. Please try again';
@@ -149,7 +158,7 @@ const PropertyDetailPage: React.FC = () => {
); );
} else { } else {
const { data } = await axiosInstance.post<SavedPropertiesAPI>(`/saved-properties/`, { const { data } = await axiosInstance.post<SavedPropertiesAPI>(`/saved-properties/`, {
property: property.id, property: property.uuid4,
user: account.id, user: account.id,
}); });
setSavedProperty(data); 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); console.log('handle delete. IMPLEMENT ME', propertyId);
}; };
@@ -277,7 +286,7 @@ const PropertyDetailPage: React.FC = () => {
isPublicPage={true} isPublicPage={true}
onSave={handleSaveProperty} onSave={handleSaveProperty}
isOwnerView={isOwnerOfProperty} isOwnerView={isOwnerOfProperty}
onDelete={() => handleDeleteProperty(property.id)} onDelete={() => handleDeleteProperty(property.uuid4)}
/> />
</Grid> </Grid>
@@ -296,6 +305,11 @@ const PropertyDetailPage: React.FC = () => {
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} /> <EstimatedMonthlyCostCard price={parseFloat(priceForAnalysis)} />
</Grid> </Grid>
{isOwnerOfProperty && features.enableMLSPublishing && (
<Grid size={{ xs: 12 }}>
<MLSPublishingCard />
</Grid>
)}
{!isOwnerOfProperty && ( {!isOwnerOfProperty && (
<> <>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>

View File

@@ -72,9 +72,9 @@ const SupportAgentDashboard: React.FC = () => {
text: item.description, text: item.description,
created_at: item.created_at, created_at: item.created_at,
updated_at: item.updated_at, updated_at: item.updated_at,
user: -1, // Placeholder user: item.user_email,
user_first_name: 'System', user_first_name: item.user_first_name,
user_last_name: 'Description' user_last_name: item.user_last_name,
}] }]
})); }));
@@ -129,7 +129,7 @@ const SupportAgentDashboard: React.FC = () => {
result = result.filter(item => result = result.filter(item =>
item.title.toLowerCase().includes(lowerSearch) || item.title.toLowerCase().includes(lowerSearch) ||
item.description.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) { if (loading) {
return <PageLoader />; return <PageLoader />;
} }
@@ -233,7 +253,8 @@ const SupportAgentDashboard: React.FC = () => {
return ( return (
<Container <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 <Paper
elevation={3} elevation={3}
@@ -242,7 +263,9 @@ const SupportAgentDashboard: React.FC = () => {
<Grid container sx={{ height: '100%', width: '100%' }}> <Grid container sx={{ height: '100%', width: '100%' }}>
{/* Left Panel: List & Filters */} {/* Left Panel: List & Filters */}
<Grid <Grid
size={{ xs: 12, md: 4 }} item
xs={12}
md={4}
sx={{ sx={{
borderRight: { md: '1px solid', borderColor: 'grey.200' }, borderRight: { md: '1px solid', borderColor: 'grey.200' },
display: 'flex', display: 'flex',
@@ -337,6 +360,7 @@ const SupportAgentDashboard: React.FC = () => {
</Box> </Box>
} }
/> />
</ListItemButton>
</ListItem> </ListItem>
)) ))
)} )}
@@ -354,12 +378,21 @@ const SupportAgentDashboard: React.FC = () => {
borderColor: 'grey.200', borderColor: 'grey.200',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
gap: 2, gap: 2,
}} }}
> >
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold' }}>
{supportCase.title} | {supportCase.category} {supportCase.title} | {supportCase.category}
</Typography> </Typography>
<Button
variant="outlined"
color="error"
onClick={handleCloseCase}
disabled={supportCase.status === 'closed'}
>
{supportCase.status === 'closed' ? 'Closed' : 'Close Case'}
</Button>
</Box> </Box>
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}> <Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3, bgcolor: 'grey.50' }}>

View File

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

View File

@@ -1,4 +1,5 @@
import { import {
Alert,
Button, Button,
FormControl, FormControl,
InputAdornment, InputAdornment,
@@ -10,12 +11,36 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import Image from 'components/base/Image'; import Image from 'components/base/Image';
import { Suspense } from 'react'; import { Suspense, useState } from 'react';
import forgotPassword from 'assets/authentication-banners/green.png'; import forgotPassword 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 { axiosInstance } from '../../axiosApi.js';
const ForgotPassword = () => { 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 ( return (
<Stack <Stack
direction="row" direction="row"
@@ -30,14 +55,18 @@ const ForgotPassword = () => {
</Link> </Link>
<Stack alignItems="center" gap={6.5} width={330} mx="auto"> <Stack alignItems="center" gap={6.5} width={330} mx="auto">
<Typography variant="h3">Forgot Password</Typography> <Typography variant="h3">Forgot Password</Typography>
{successMessage && <Alert severity="success">{successMessage}</Alert>}
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="new-password"> <InputLabel shrink htmlFor="email">
Email Email
</InputLabel> </InputLabel>
<TextField <TextField
variant="filled" variant="filled"
placeholder="Enter your email" placeholder="Enter your email"
id="email" id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@@ -47,8 +76,13 @@ const ForgotPassword = () => {
}} }}
/> />
</FormControl> </FormControl>
<Button variant="contained" fullWidth> <Button
Send Password Reset Link variant="contained"
fullWidth
onClick={handleResetPassword}
disabled={loading || !email}
>
{loading ? 'Sending...' : 'Send Password Reset Link'}
</Button> </Button>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Back to{' '} Back to{' '}

View File

@@ -20,6 +20,7 @@ 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';
import paths from 'routes/paths';
type loginValues = { type loginValues = {
email: string; email: string;
@@ -50,7 +51,15 @@ const Login = (): ReactElement => {
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } 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) { if (hasErrors) {
setErrorMessage(error.response.data); setErrorMessage(error.response.data);
} else { } else {

View 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();
});
});

View File

@@ -1,5 +1,6 @@
import { ReactElement, Suspense, useState } from 'react'; import { ReactElement, Suspense, useState } from 'react';
import { import {
Alert,
Button, Button,
FormControl, FormControl,
IconButton, IconButton,
@@ -17,30 +18,55 @@ 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'; import { useSearchParams } from 'react-router-dom';
import { axiosInstance } from '../../axiosApi.js';
const ResetPassword = (): ReactElement => { const ResetPassword = (): ReactElement => {
const [searchParams] = useSearchParams();
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [password, setPassword] = useState(''); 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 handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword); const handleClickShowConfirmPassword = () => setShowConfirmPassword(!showConfirmPassword);
const [resetSuccessful, setResetSuccessful] = useState(false);
const handleResetPassword = () => { const handleResetPassword = async () => {
const passwordField: HTMLInputElement = document.getElementById( if (password !== confirmPassword) {
'new-password', setErrorMessage("Passwords don't match");
) as HTMLInputElement;
const confirmPasswordField: HTMLInputElement = document.getElementById(
'confirm-password',
) as HTMLInputElement;
if (passwordField.value !== confirmPasswordField.value) {
alert("Passwords don't match");
return; return;
} }
setResetSuccessful(true);
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 ( return (
@@ -58,6 +84,45 @@ const ResetPassword = (): ReactElement => {
{!resetSuccessful ? ( {!resetSuccessful ? (
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> <Stack alignItems="center" gap={3.75} width={330} mx="auto">
<Typography variant="h3">Reset Password</Typography> <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> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="new-password"> <InputLabel shrink htmlFor="new-password">
Password Password
@@ -67,6 +132,7 @@ const ResetPassword = (): ReactElement => {
placeholder="Enter new password" placeholder="Enter new password"
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
id="new-password" id="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
@@ -89,17 +155,18 @@ const ResetPassword = (): ReactElement => {
), ),
}} }}
/> />
{/*<PasswordStrengthChecker password={password} />*/}
</FormControl> </FormControl>
<FormControl variant="standard" fullWidth> <FormControl variant="standard" fullWidth>
<InputLabel shrink htmlFor="confirm-password"> <InputLabel shrink htmlFor="confirm-password">
Password Confirm Password
</InputLabel> </InputLabel>
<TextField <TextField
variant="filled" variant="filled"
placeholder="Confirm password" placeholder="Confirm password"
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
id="confirm-password" id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@@ -122,8 +189,13 @@ const ResetPassword = (): ReactElement => {
}} }}
/> />
</FormControl> </FormControl>
<Button variant="contained" fullWidth onClick={handleResetPassword}> <Button
Reset Password variant="contained"
fullWidth
onClick={handleResetPassword}
disabled={loading || !password || !confirmPassword}
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button> </Button>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Back to{' '} Back to{' '}
@@ -140,7 +212,7 @@ const ResetPassword = (): ReactElement => {
<Stack alignItems="center" gap={3.75} width={330} mx="auto"> <Stack alignItems="center" gap={3.75} width={330} mx="auto">
<Image src={successTick} /> <Image src={successTick} />
<Typography variant="h3">Reset Successfully</Typography> <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 Your Ditch the Agent log in password has been updated successfully
</Typography> </Typography>
<Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login"> <Button variant="contained" fullWidth LinkComponent={Link} href="/authentication/login">

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

View File

@@ -29,6 +29,7 @@ export default {
home: `/${rootPaths.homeRoot}`, home: `/${rootPaths.homeRoot}`,
dashboard: `/${rootPaths.dashboardRoot}`, dashboard: `/${rootPaths.dashboardRoot}`,
login: `/${rootPaths.authRoot}/login`, login: `/${rootPaths.authRoot}/login`,
verifyAccount: `/${rootPaths.authRoot}/verify-account`,
signup: `/${rootPaths.authRoot}/sign-up`, signup: `/${rootPaths.authRoot}/sign-up`,
resetPassword: `/${rootPaths.authRoot}/reset-password`, resetPassword: `/${rootPaths.authRoot}/reset-password`,
forgotPassword: `/${rootPaths.authRoot}/forgot-password`, forgotPassword: `/${rootPaths.authRoot}/forgot-password`,

View File

@@ -76,6 +76,7 @@ const Error404 = lazy(async () => {
}); });
const Login = lazy(async () => import('pages/authentication/Login')); 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 SignUp = lazy(async () => import('pages/authentication/SignUp'));
const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword')); const ResetPassword = lazy(async () => import('pages/authentication/ResetPassword'));
@@ -179,6 +180,10 @@ const routes: RouteObject[] = [
path: paths.login, path: paths.login,
element: <Login />, element: <Login />,
}, },
{
path: paths.verifyAccount,
element: <VerifyAccount />,
},
{ {
path: paths.signup, path: paths.signup,
element: <SignUp />, element: <SignUp />,

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -94,6 +94,7 @@ export interface MessagesAPI {
export interface UserAPI { export interface UserAPI {
id: number; id: number;
uuid4: string;
email: string; email: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
@@ -268,6 +269,7 @@ export interface WalkScoreAPI {
export interface PropertiesAPI { export interface PropertiesAPI {
id: number; id: number;
uuid4: string;
owner: PropertyOwnerAPI; owner: PropertyOwnerAPI;
address: string; // full address address: string; // full address
street: string; street: string;
@@ -354,6 +356,7 @@ export interface LenderFinancingAgreementData {
export interface DocumentAPI { export interface DocumentAPI {
id: number; id: number;
uuid4: string;
property: number; property: number;
file: string; file: string;
document_type: string; document_type: string;
@@ -370,7 +373,7 @@ export interface DocumentAPI {
| LenderFinancingAgreementData; | LenderFinancingAgreementData;
} }
export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id'> { export interface PropertyRequestAPI extends Omit<PropertiesAPI, 'owner' | 'id' | 'uuid4'> {
owner: number; owner: number;
} }
@@ -770,7 +773,7 @@ export interface FaqApi {
export interface SupportMessageApi { export interface SupportMessageApi {
id: number; id: number;
text: string; text: string;
user: id; user: number;
user_first_name: string; user_first_name: string;
user_last_name: string; user_last_name: string;
created_at: string; created_at: string;
@@ -786,5 +789,8 @@ export interface SupportCaseApi {
messages: SupportMessageApi[]; messages: SupportMessageApi[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
user_email: string;
user_first_name: string;
user_last_name: string;
} }
// Walk Score API Type Definitions // Walk Score API Type Definitions

View File

@@ -16,4 +16,9 @@ export default defineConfig({
port: 3000, port: 3000,
}, },
base: '/', base: '/',
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
},
}); });