Compare commits

..

5 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
1570185792 closes #12 2025-12-11 15:28:33 -06:00
c48fc96b33 closes #8 2025-12-11 13:16:54 -06:00
31 changed files with 3285 additions and 255 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",
"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"
}
}

View File

@@ -1,7 +1,8 @@
import axios from 'axios';
import Cookies from 'js-cookie';
import { features } from './config/features';
const baseURL = import.meta.env.VITE_API_URL;
const baseURL = features.apiUrl;
console.log(baseURL);
export const axiosRealEstateApi = axios.create({

View File

@@ -395,11 +395,17 @@ const ChatPane = ({ showChat, isMinimized, toggleMinimize, closeChat }: ChatPane
);
};
const FloatingChatButton = (): ReactElement => {
import { features } from 'config/features';
const FloatingChatButton = (): ReactElement | null => {
const [showChat, setShowChat] = useState<boolean>(false);
// State to control if the chat pane is minimized
const [isMinimized, setIsMinimized] = useState<boolean>(false);
if (!features.enableFloatingChatButton) {
return null;
}
// Function to toggle the chat pane visibility
const toggleChat = () => {
setShowChat(!showChat);

View File

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

View File

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

View File

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

View File

@@ -27,12 +27,15 @@ import {
import { test_property_search } from 'data/mock_property_search';
import { extractLatLon } from 'utils';
import { test_autocomplete } from 'data/mock_autocomplete_results';
import { features } from '../../../../../config/features';
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;
}
@@ -62,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: '',
@@ -85,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,
});
@@ -204,7 +218,7 @@ const AddPropertyDialog: React.FC<AddPropertyDialogProps> = ({ open, onClose, on
console.log('here we go', value);
if (value) {
console.log('find the test data');
const test: boolean = import.meta.env.USE_LIVE_DATA;
const test: boolean = features.useLiveData;
if (test) {
const parts: string[] =
test_property_search.data.currentMortgages[0].recordingDate.split('T');
@@ -217,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,
@@ -301,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,

View File

@@ -25,6 +25,7 @@ import { PlacePrediction } from './AddPropertyDialog';
import { test_autocomplete } from 'data/mock_autocomplete_results';
import { axiosInstance, axiosRealEstateApi } from '../../../../../axiosApi';
import { extractLatLon } from 'utils';
import { features } from '../../../../../config/features';
interface AttorneyProfileCardProps {
attorney: AttorneyAPI;
@@ -115,7 +116,7 @@ const AttorneyProfileCard: React.FC<AttorneyProfileCardProps> = ({
event: React.SyntheticEvent,
value: string,
) => {
const test: boolean = !import.meta.env.USE_LIVE_DATA;
const test: boolean = !features.useLiveData;
let data: AutocompleteDataResponseAPI[] = [];
if (value.length > 2) {
if (test) {

View File

@@ -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) {

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

View File

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

View File

@@ -0,0 +1,67 @@
/**
* Feature flags and environment configuration
* Controls feature availability and environment settings based on build mode (production, beta, development)
*/
interface FeatureConfig {
// Registration features
enableAttorneyRegistration: boolean;
enableRealEstateAgentRegistration: boolean;
enableRegistration: boolean;
// UI Features
enableFloatingChatButton: boolean;
enableMLSPublishing: boolean;
// API Configuration
apiUrl: string;
// Data Configuration
useLiveData: boolean;
}
/**
* Get feature configuration based on current environment
*/
const getFeatureConfig = (): FeatureConfig => {
const mode = import.meta.env.MODE || 'development';
// Production configuration
if (mode === 'production') {
return {
enableAttorneyRegistration: false,
enableRealEstateAgentRegistration: false,
enableRegistration: false,
enableFloatingChatButton: false,
enableMLSPublishing: false,
apiUrl: 'https://backend.ditchtheagent.com/api/',
useLiveData: true,
};
}
// Beta configuration
if (mode === 'beta') {
return {
enableAttorneyRegistration: true,
enableRealEstateAgentRegistration: true,
enableRegistration: true,
enableFloatingChatButton: true,
enableMLSPublishing: true,
apiUrl: 'https://beta.backend.ditchtheagent.com/api/',
useLiveData: true,
};
}
// Development configuration (default)
return {
enableAttorneyRegistration: true,
enableRealEstateAgentRegistration: true,
enableRegistration: true,
enableFloatingChatButton: true,
enableMLSPublishing: true,
apiUrl: 'http://127.0.0.1:8010/api/',
useLiveData: false,
};
};
export const features = getFeatureConfig();

View File

@@ -1,6 +1,7 @@
import React, { useEffect, createContext, useRef, useState, useContext, ReactNode } from 'react';
import { AccountContext } from './AccountContext';
import { AuthContext } from './AuthContext';
import { features } from '../config/features';
// ---
// Define Types and Interfaces
@@ -141,7 +142,7 @@ function WebSocketProvider({ children }: WebSocketProviderProps) {
ws.current.close();
}
const wsUrl = new URL(import.meta.env.VITE_API_URL || 'ws://127.0.0.1:8010/ws/');
const wsUrl = new URL(features.apiUrl || 'ws://127.0.0.1:8010/ws/');
wsUrl.protocol = wsUrl.protocol.replace('http', 'ws');
ws.current = new WebSocket(

View File

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

View File

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

View File

@@ -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' }}>

View File

@@ -20,8 +20,9 @@ import {
Switch,
Typography,
} from '@mui/material';
import { features } from '../../config/features';
const base_url: string = `${import.meta.env.VITE_API_URL?.replace('/api/', '')}/media/vendor_pictures/`;
const base_url: string = `${features.apiUrl?.replace('/api/', '')}/media/vendor_pictures/`;
// Define the array of corrected and alphabetized categories with 'as const'
export const CATEGORY_NAMES = [

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 {
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{' '}

View File

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

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

View File

@@ -24,6 +24,7 @@ import Image from 'components/base/Image';
import { axiosInstance } from '../../axiosApi.js';
import PasswordStrengthChecker from '../../components/PasswordStrengthChecker';
import { useNavigate } from 'react-router-dom';
import { features } from '../../config/features';
type SignUpValues = {
email: string;
@@ -191,10 +192,12 @@ const SignUp = (): ReactElement => {
</InputLabel>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
{[
{ value: 'property_owner', label: 'Home Buyer/Seller', icon: 'mdi:home-account' },
{ value: 'attorney', label: 'Attorney', icon: 'mdi:gavel' },
{ value: 'vendor', label: 'Vendor', icon: 'mdi:briefcase' },
].map((type) => (
{ value: 'property_owner', label: 'Home Buyer/Seller', icon: 'mdi:home-account', enabled: true },
{ value: 'attorney', label: 'Attorney', icon: 'mdi:gavel', enabled: features.enableAttorneyRegistration },
{ value: 'vendor', label: 'Vendor', icon: 'mdi:briefcase', enabled: features.enableRealEstateAgentRegistration },
]
.filter((type) => type.enabled)
.map((type) => (
<Paper
key={type.value}
variant="outlined"

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}`,
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`,

View File

@@ -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 />,

View File

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

View File

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

View File

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