Compare commits

..

2 Commits

Author SHA1 Message Date
jackiettran
5ec22c2a5b Navbar UX consistency 2025-12-23 19:39:23 -05:00
jackiettran
426f974ed3 users can click outside of modal to close the modal for info only modals. Take away that ability for important modals 2025-12-23 18:43:17 -05:00
8 changed files with 169 additions and 181 deletions

View File

@@ -86,12 +86,6 @@ describe('Navbar', () => {
expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument();
}); });
it('should render location input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('City or ZIP')).toBeInTheDocument();
});
it('should render search button', () => { it('should render search button', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
@@ -122,36 +116,6 @@ describe('Navbar', () => {
expect(mockNavigate).toHaveBeenCalledWith('/items?search=tent'); expect(mockNavigate).toHaveBeenCalledWith('/items?search=tent');
}); });
it('should append city to search URL', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'kayak' } });
fireEvent.change(locationInput, { target: { value: 'Seattle' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=kayak&city=Seattle');
});
it('should append zipCode when location is a zip code', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'bike' } });
fireEvent.change(locationInput, { target: { value: '98101' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=bike&zipCode=98101');
});
it('should clear search fields after search', () => { it('should clear search fields after search', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
@@ -169,16 +133,38 @@ describe('Navbar', () => {
it('should show login button when user is not logged in', () => { it('should show login button when user is not logged in', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
expect(screen.getByRole('button', { name: 'Login or Sign Up' })).toBeInTheDocument(); // There are two login buttons (mobile + desktop)
const loginButtons = screen.getAllByRole('button', { name: 'Login or Sign Up' });
expect(loginButtons.length).toBeGreaterThan(0);
}); });
it('should call openAuthModal when login button is clicked', () => { it('should call openAuthModal when login button is clicked', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
fireEvent.click(screen.getByRole('button', { name: 'Login or Sign Up' })); // Click the first login button (either mobile or desktop)
const loginButtons = screen.getAllByRole('button', { name: 'Login or Sign Up' });
fireEvent.click(loginButtons[0]);
expect(mockOpenAuthModal).toHaveBeenCalledWith('login'); expect(mockOpenAuthModal).toHaveBeenCalledWith('login');
}); });
it('should show Forum link when logged out', () => {
renderWithRouter(<Navbar />);
// There are two Forum links (mobile + desktop), both should link to /forum
const forumLinks = screen.getAllByRole('link', { name: /Forum/i });
expect(forumLinks.length).toBeGreaterThan(0);
forumLinks.forEach(link => {
expect(link).toHaveAttribute('href', '/forum');
});
});
it('should show hamburger icon for mobile toggle when logged out', () => {
renderWithRouter(<Navbar />);
const hamburgerIcon = document.querySelector('.bi-list');
expect(hamburgerIcon).toBeInTheDocument();
});
}); });
describe('Logged In State', () => { describe('Logged In State', () => {
@@ -191,10 +177,11 @@ describe('Navbar', () => {
}; };
}); });
it('should show user name when logged in', () => { it('should show user avatar when logged in', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
expect(screen.getByText('John')).toBeInTheDocument(); // Avatar displays user initials (JD for John Doe) - may appear multiple times (mobile + desktop)
expect(screen.getAllByText('JD').length).toBeGreaterThan(0);
}); });
it('should not show login button when logged in', () => { it('should not show login button when logged in', () => {
@@ -250,19 +237,26 @@ describe('Navbar', () => {
}); });
}); });
describe('Start Earning Link', () => {
it('should show Start Earning link', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: 'Start Earning' })).toHaveAttribute('href', '/create-item');
});
});
describe('Mobile Navigation', () => { describe('Mobile Navigation', () => {
it('should render mobile toggle button', () => { it('should render mobile toggle button', () => {
renderWithRouter(<Navbar />); renderWithRouter(<Navbar />);
expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument(); expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument();
}); });
it('should show avatar in mobile toggle when logged in', () => {
mockUser = {
id: 'user-123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};
renderWithRouter(<Navbar />);
// Should have avatar with initials, not hamburger icon
expect(screen.getAllByText('JD').length).toBeGreaterThan(0);
expect(document.querySelector('.bi-list')).not.toBeInTheDocument();
});
}); });
}); });

View File

@@ -24,6 +24,16 @@ const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onHide();
};
if (show) {
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [show, onHide]);
useEffect(() => { useEffect(() => {
const fetchImageUrls = async () => { const fetchImageUrls = async () => {
if (!conditionCheck?.imageFilenames?.length) return; if (!conditionCheck?.imageFilenames?.length) return;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect } from "react";
import { rentalAPI } from "../services/api"; import { rentalAPI } from "../services/api";
import { Rental } from "../types"; import { Rental } from "../types";
@@ -65,38 +65,17 @@ const DeclineRentalModal: React.FC<DeclineRentalModalProps> = ({
onHide(); onHide();
}; };
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget && !processing) {
handleClose();
}
},
[handleClose, processing]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && !processing) {
handleClose();
}
},
[handleClose, processing]
);
useEffect(() => { useEffect(() => {
if (show) { if (show) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} else { } else {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
}; };
}, [show, handleKeyDown]); }, [show]);
if (!show) return null; if (!show) return null;
@@ -104,7 +83,6 @@ const DeclineRentalModal: React.FC<DeclineRentalModalProps> = ({
<div <div
className="modal d-block" className="modal d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={handleBackdropClick}
> >
<div className="modal-dialog modal-dialog-centered"> <div className="modal-dialog modal-dialog-centered">
<div className="modal-content"> <div className="modal-content">

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useSocket } from "../contexts/SocketContext"; import { useSocket } from "../contexts/SocketContext";
import { rentalAPI, messageAPI } from "../services/api"; import { rentalAPI, messageAPI } from "../services/api";
import Avatar from "./Avatar";
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
const { user, logout, openAuthModal } = useAuth(); const { user, logout, openAuthModal } = useAuth();
@@ -137,7 +138,7 @@ const Navbar: React.FC = () => {
</div> </div>
</div> </div>
{/* Mobile avatar toggle */} {/* Mobile menu toggle */}
<button <button
className="navbar-toggler border-0 p-0" className="navbar-toggler border-0 p-0"
type="button" type="button"
@@ -147,46 +148,50 @@ const Navbar: React.FC = () => {
aria-expanded="false" aria-expanded="false"
aria-label="Toggle navigation" aria-label="Toggle navigation"
> >
<span {user ? (
style={{ <span
display: "flex", style={{
alignItems: "center", display: "flex",
position: "relative", alignItems: "center",
}} position: "relative",
> }}
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && ( >
<span {(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
className="mobile-notification-badge" <span
style={{ className="mobile-notification-badge"
position: "absolute", style={{
right: "-5px", position: "absolute",
top: "-5px", right: "-5px",
backgroundColor: "#dc3545", top: "-5px",
color: "white", backgroundColor: "#dc3545",
borderRadius: "50%", color: "white",
width: "1.2em", borderRadius: "50%",
height: "1.2em", width: "1.2em",
display: "flex", height: "1.2em",
alignItems: "center", display: "flex",
justifyContent: "center", alignItems: "center",
fontSize: "0.7em", justifyContent: "center",
fontWeight: "bold", fontSize: "0.7em",
border: "2px solid white", fontWeight: "bold",
zIndex: 1, border: "2px solid white",
}} zIndex: 1,
> }}
{pendingRequestsCount + unreadMessagesCount} >
</span> {pendingRequestsCount + unreadMessagesCount}
)} </span>
)}
<Avatar user={user} size="sm" />
</span>
) : (
<i <i
className="bi bi-person-circle" className="bi bi-list"
style={{ fontSize: "1.5rem", color: "#333" }} style={{ fontSize: "1.5rem", color: "#333" }}
></i> ></i>
</span> )}
</button> </button>
<div className="collapse navbar-collapse" id="navbarNav"> <div className="collapse navbar-collapse" id="navbarNav">
<div className="d-flex align-items-center ms-auto"> <div className="d-flex align-items-center justify-content-center justify-content-lg-end w-100">
<ul className="navbar-nav flex-row"> <ul className="navbar-nav flex-column flex-lg-row">
{user ? ( {user ? (
<> <>
<li className="nav-item dropdown"> <li className="nav-item dropdown">
@@ -210,29 +215,26 @@ const Navbar: React.FC = () => {
<span <span
style={{ style={{
position: "absolute", position: "absolute",
left: "-0.9em", right: "-5px",
top: "50%", top: "-5px",
transform: "translateY(-50%)",
backgroundColor: "#dc3545", backgroundColor: "#dc3545",
color: "white", color: "white",
borderRadius: "50%", borderRadius: "50%",
width: "1.5em", width: "1.2em",
height: "1.5em", height: "1.2em",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontSize: "0.85em", fontSize: "0.7em",
fontWeight: "bold", fontWeight: "bold",
border: "2px solid white", border: "2px solid white",
opacity: 1,
zIndex: 1, zIndex: 1,
}} }}
> >
{pendingRequestsCount + unreadMessagesCount} {pendingRequestsCount + unreadMessagesCount}
</span> </span>
)} )}
<i className="bi bi-person-circle me-1"></i> <Avatar user={user} size="sm" />
{user.firstName}
</span> </span>
</a> </a>
<ul <ul
@@ -298,14 +300,36 @@ const Navbar: React.FC = () => {
</li> </li>
</> </>
) : ( ) : (
<li className="nav-item"> <>
<button <li className="nav-item text-center text-lg-end">
className="btn btn-primary btn-sm text-nowrap" <Link
onClick={() => openAuthModal("login")} className="nav-link d-lg-none"
> to="/forum"
Login or Sign Up >
</button> Forum
</li> </Link>
<Link
className="btn btn-outline-primary btn-sm text-nowrap d-none d-lg-inline-block me-2"
to="/forum"
>
Forum
</Link>
</li>
<li className="nav-item text-center text-lg-end">
<button
className="nav-link d-lg-none w-100 border-0 bg-transparent"
onClick={() => openAuthModal("login")}
>
Login or Sign Up
</button>
<button
className="btn btn-primary btn-sm text-nowrap d-none d-lg-inline-block"
onClick={() => openAuthModal("login")}
>
Login or Sign Up
</button>
</li>
</>
)} )}
</ul> </ul>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect } from "react";
import { rentalAPI } from "../services/api"; import { rentalAPI } from "../services/api";
import { RefundPreview, Rental } from "../types"; import { RefundPreview, Rental } from "../types";
@@ -129,38 +129,17 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
return "success"; return "success";
}; };
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
},
[handleClose]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
},
[handleClose]
);
useEffect(() => { useEffect(() => {
if (show) { if (show) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} else { } else {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
}; };
}, [show, handleKeyDown]); }, [show]);
if (!show) return null; if (!show) return null;
@@ -168,7 +147,6 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
<div <div
className="modal d-block" className="modal d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={handleBackdropClick}
> >
<div className="modal-dialog modal-lg modal-dialog-centered"> <div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content"> <div className="modal-content">

View File

@@ -353,38 +353,17 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
onHide(); onHide();
}, [onHide]); }, [onHide]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
},
[handleClose]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
},
[handleClose]
);
useEffect(() => { useEffect(() => {
if (show) { if (show) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} else { } else {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
}; };
}, [show, handleKeyDown]); }, [show]);
if (!show) return null; if (!show) return null;
@@ -392,7 +371,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
<div <div
className="modal d-block" className="modal d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={handleBackdropClick}
> >
<div className="modal-dialog modal-lg modal-dialog-centered"> <div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content"> <div className="modal-content">

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
import { Rental } from "../types"; import { Rental } from "../types";
import StarRating from "./StarRating"; import StarRating from "./StarRating";
@@ -15,6 +15,16 @@ const ReviewDetailsModal: React.FC<ReviewDetailsModalProps> = ({
rental, rental,
userType, userType,
}) => { }) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (show) {
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [show, onClose]);
if (!show) return null; if (!show) return null;
const formatDateTime = (dateString: string) => { const formatDateTime = (dateString: string) => {
@@ -29,6 +39,9 @@ const ReviewDetailsModal: React.FC<ReviewDetailsModalProps> = ({
className="modal d-block" className="modal d-block"
tabIndex={-1} tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
> >
<div className="modal-dialog modal-dialog-centered modal-lg"> <div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content"> <div className="modal-content">

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
interface SuccessModalProps { interface SuccessModalProps {
show: boolean; show: boolean;
@@ -7,19 +7,32 @@ interface SuccessModalProps {
message: string; message: string;
} }
const SuccessModal: React.FC<SuccessModalProps> = ({ const SuccessModal: React.FC<SuccessModalProps> = ({
show, show,
onClose, onClose,
title = "Success!", title = "Success!",
message message
}) => { }) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (show) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
}, [show, onClose]);
if (!show) return null; if (!show) return null;
return ( return (
<div <div
className="modal d-block" className="modal d-block"
tabIndex={-1} tabIndex={-1}
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
> >
<div className="modal-dialog modal-dialog-centered"> <div className="modal-dialog modal-dialog-centered">
<div className="modal-content"> <div className="modal-content">