setDrawerOpen(false)}
+ data-testid="drawer-overlay"
+ />
+ )}
+
+ {/* Mobile drawer */}
+
+
+
{brandName}
+
setDrawerOpen(false)}
+ aria-label="Close menu"
+ data-testid="close-drawer-button"
+ className="rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
+ >
+
+
+
+
+
+
+ {navLinks.map((link) => (
+ handleLinkClick(link.href)}
+ className="border-b border-gray-50 py-3 text-left text-base font-medium text-gray-700 transition-colors hover:text-gray-900"
+ >
+ {link.label}
+
+ ))}
+
+
+ >
+ );
+};
+
+export default Header;
diff --git a/frontend/src/components/HeroSection.test.tsx b/frontend/src/components/HeroSection.test.tsx
new file mode 100644
index 0000000..a44e2a7
--- /dev/null
+++ b/frontend/src/components/HeroSection.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import HeroSection, { HeroSectionProps } from './HeroSection';
+
+const defaultProps: HeroSectionProps = {
+ headline: 'Find Your Dream Home',
+ subheading: 'Discover luxury properties in the most sought-after neighborhoods.',
+ backgroundImageUrl:
+ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1600&h=900&fit=crop',
+};
+
+describe('HeroSection', () => {
+ it('renders without crashing', () => {
+ render(
);
+ expect(screen.getByTestId('hero-section')).toBeTruthy();
+ });
+
+ it('displays the headline text', () => {
+ render(
);
+ expect(screen.getByTestId('hero-headline').textContent).toBe(
+ 'Find Your Dream Home'
+ );
+ });
+
+ it('displays the subheading text', () => {
+ render(
);
+ expect(screen.getByTestId('hero-subheading').textContent).toBe(
+ 'Discover luxury properties in the most sought-after neighborhoods.'
+ );
+ });
+
+ it('renders the background image with the correct URL', () => {
+ render(
);
+ const bg = screen.getByTestId('hero-background');
+ expect(bg.style.backgroundImage).toContain(defaultProps.backgroundImageUrl);
+ });
+
+ it('renders the dark overlay', () => {
+ render(
);
+ expect(screen.getByTestId('hero-overlay')).toBeTruthy();
+ });
+
+ it('uses custom overlay opacity when provided', () => {
+ render(
);
+ const overlay = screen.getByTestId('hero-overlay');
+ expect(overlay.style.backgroundColor).toBe('rgba(0, 0, 0, 0.7)');
+ });
+
+ it('renders the search form with default placeholder', () => {
+ render(
);
+ const input = screen.getByTestId('hero-search-input') as HTMLInputElement;
+ expect(input.placeholder).toBe(
+ 'Search by city, neighborhood, or ZIP...'
+ );
+ });
+
+ it('renders custom search placeholder', () => {
+ render(
+
+ );
+ const input = screen.getByTestId('hero-search-input') as HTMLInputElement;
+ expect(input.placeholder).toBe('Enter an address...');
+ });
+
+ it('renders the CTA button with default label', () => {
+ render(
);
+ expect(screen.getByTestId('hero-cta-button').textContent).toBe('Search');
+ });
+
+ it('renders a custom CTA button label', () => {
+ render(
);
+ expect(screen.getByTestId('hero-cta-button').textContent).toBe('Explore');
+ });
+
+ it('calls onSearch with the input value when the form is submitted', () => {
+ const onSearch = vi.fn();
+ render(
);
+
+ const input = screen.getByTestId('hero-search-input');
+ fireEvent.change(input, { target: { value: 'Beverly Hills' } });
+
+ const form = screen.getByTestId('hero-search-form');
+ fireEvent.submit(form);
+
+ expect(onSearch).toHaveBeenCalledTimes(1);
+ expect(onSearch).toHaveBeenCalledWith('Beverly Hills');
+ });
+
+ it('does not throw when submitted without an onSearch handler', () => {
+ render(
);
+ const form = screen.getByTestId('hero-search-form');
+ expect(() => fireEvent.submit(form)).not.toThrow();
+ });
+
+ it('applies the custom minHeight', () => {
+ render(
);
+ const section = screen.getByTestId('hero-section');
+ expect(section.style.minHeight).toBe('80vh');
+ });
+
+ it('defaults minHeight to 70vh', () => {
+ render(
);
+ const section = screen.getByTestId('hero-section');
+ expect(section.style.minHeight).toBe('70vh');
+ });
+
+ it('updates search input value when user types', () => {
+ render(
);
+ const input = screen.getByTestId('hero-search-input') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'Malibu' } });
+ expect(input.value).toBe('Malibu');
+ });
+});
diff --git a/frontend/src/components/HeroSection.tsx b/frontend/src/components/HeroSection.tsx
new file mode 100644
index 0000000..7d19353
--- /dev/null
+++ b/frontend/src/components/HeroSection.tsx
@@ -0,0 +1,192 @@
+import React, { useState, useEffect } from 'react';
+
+export interface HeroSectionProps {
+ /** Main headline text */
+ headline: string;
+ /** Subheading text below the headline */
+ subheading: string;
+ /** Background image URL (Unsplash or any valid image URL) */
+ backgroundImageUrl: string;
+ /** Placeholder text for the search input */
+ searchPlaceholder?: string;
+ /** Label for the CTA / search button */
+ ctaButtonLabel?: string;
+ /** Callback when the user submits a search query */
+ onSearch?: (query: string) => void;
+ /** Minimum height of the hero section (CSS value) */
+ minHeight?: string;
+ /** Overlay opacity from 0 to 1 */
+ overlayOpacity?: number;
+}
+
+const HeroSection: React.FC
= ({
+ headline,
+ subheading,
+ backgroundImageUrl,
+ searchPlaceholder = 'Search by city, neighborhood, or ZIP...',
+ ctaButtonLabel = 'Search',
+ onSearch,
+ minHeight = '70vh',
+ overlayOpacity = 0.55,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setIsVisible(true), 100);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSearch?.(searchQuery);
+ };
+
+ return (
+
+ {/* Background Image */}
+
+
+ {/* Dark Overlay */}
+
+
+ {/* Content */}
+
+
+ {headline}
+
+
+
+ {subheading}
+
+
+
+
+
+ );
+};
+
+export default HeroSection;
diff --git a/frontend/src/components/Input.test.tsx b/frontend/src/components/Input.test.tsx
new file mode 100644
index 0000000..697d4e9
--- /dev/null
+++ b/frontend/src/components/Input.test.tsx
@@ -0,0 +1,180 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import Input from './Input';
+
+describe('Input', () => {
+ it('renders without crashing', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByLabelText('Name')).toBeInTheDocument();
+ });
+
+ it('renders a label with the provided text', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByText('Email Address')).toBeInTheDocument();
+ });
+
+ it('displays the placeholder text', () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.getByPlaceholderText('Enter your phone')).toBeInTheDocument();
+ });
+
+ it('calls onChange when user types', () => {
+ const handleChange = vi.fn();
+ render(
+
+ );
+ const input = screen.getByLabelText('Username');
+ fireEvent.change(input, { target: { value: 'hello' } });
+ expect(handleChange).toHaveBeenCalledWith('hello');
+ });
+
+ it('renders the controlled value', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByLabelText('City')).toHaveValue('Portland');
+ });
+
+ it('renders an error message when error prop is provided', () => {
+ render(
+ {}}
+ error="Email is required"
+ />
+ );
+ const errorMsg = screen.getByRole('alert');
+ expect(errorMsg).toHaveTextContent('Email is required');
+ });
+
+ it('applies aria-invalid when error is present', () => {
+ render(
+ {}}
+ error="Invalid email"
+ />
+ );
+ expect(screen.getByLabelText(/Email/)).toHaveAttribute('aria-invalid', 'true');
+ });
+
+ it('does not render error element when no error', () => {
+ render(
+ {}} />
+ );
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('renders a textarea when type is textarea', () => {
+ render(
+ {}}
+ type="textarea"
+ />
+ );
+ const textarea = screen.getByLabelText('Message');
+ expect(textarea.tagName).toBe('TEXTAREA');
+ });
+
+ it('renders an input with correct type for email', () => {
+ render(
+ {}} type="email" />
+ );
+ const input = screen.getByLabelText('Email');
+ expect(input).toHaveAttribute('type', 'email');
+ });
+
+ it('renders an input with correct type for tel', () => {
+ render(
+ {}} type="tel" />
+ );
+ const input = screen.getByLabelText('Phone');
+ expect(input).toHaveAttribute('type', 'tel');
+ });
+
+ it('renders an input with correct type for number', () => {
+ render(
+ {}} type="number" />
+ );
+ const input = screen.getByLabelText('Age');
+ expect(input).toHaveAttribute('type', 'number');
+ });
+
+ it('shows the required asterisk when required is true', () => {
+ render(
+ {}} required />
+ );
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('does not show the required asterisk when required is false', () => {
+ render(
+ {}} />
+ );
+ expect(screen.queryByText('*')).not.toBeInTheDocument();
+ });
+
+ it('sets the HTML required attribute when required', () => {
+ render(
+ {}} required />
+ );
+ expect(screen.getByLabelText(/Name/)).toBeRequired();
+ });
+
+ it('applies additional className', () => {
+ const { container } = render(
+ {}}
+ className="mt-4"
+ />
+ );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper.className).toContain('mt-4');
+ });
+
+ it('sets the name attribute on the input', () => {
+ render(
+ {}} name="email" />
+ );
+ expect(screen.getByLabelText('Email')).toHaveAttribute('name', 'email');
+ });
+
+ it('disables the input when disabled is true', () => {
+ render(
+ {}} disabled />
+ );
+ expect(screen.getByLabelText('Disabled')).toBeDisabled();
+ });
+
+ it('calls onChange for textarea type', () => {
+ const handleChange = vi.fn();
+ render(
+
+ );
+ const textarea = screen.getByLabelText('Bio');
+ fireEvent.change(textarea, { target: { value: 'My bio' } });
+ expect(handleChange).toHaveBeenCalledWith('My bio');
+ });
+});
diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx
new file mode 100644
index 0000000..ff88ab4
--- /dev/null
+++ b/frontend/src/components/Input.tsx
@@ -0,0 +1,122 @@
+import React, { useId } from 'react';
+
+export interface InputProps {
+ /** Label text displayed above the input */
+ label: string;
+ /** Placeholder text for the input */
+ placeholder?: string;
+ /** Controlled value */
+ value: string;
+ /** Change handler */
+ onChange: (value: string) => void;
+ /** Input type — defaults to 'text' */
+ type?: 'text' | 'email' | 'tel' | 'number' | 'textarea';
+ /** HTML name attribute */
+ name?: string;
+ /** Whether the field is required */
+ required?: boolean;
+ /** Error message to display below the input */
+ error?: string;
+ /** Additional CSS class names */
+ className?: string;
+ /** Whether the input is disabled */
+ disabled?: boolean;
+}
+
+const Input: React.FC = ({
+ label,
+ placeholder = '',
+ value,
+ onChange,
+ type = 'text',
+ name,
+ required = false,
+ error,
+ className = '',
+ disabled = false,
+}) => {
+ const generatedId = useId();
+ const inputId = name ? `input-${name}` : generatedId;
+
+ const baseClasses = [
+ 'block',
+ 'w-full',
+ 'rounded-lg',
+ 'border',
+ 'px-4',
+ 'py-3',
+ 'text-sm',
+ 'text-gray-900',
+ 'placeholder-gray-400',
+ 'bg-white',
+ 'transition-colors',
+ 'duration-200',
+ 'outline-none',
+ disabled ? 'opacity-50 cursor-not-allowed bg-gray-50' : '',
+ error
+ ? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200'
+ : 'border-gray-300 focus:border-blue-600 focus:ring-2 focus:ring-blue-200',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ const handleChange = (
+ e: React.ChangeEvent
+ ) => {
+ onChange(e.target.value);
+ };
+
+ const sharedProps = {
+ id: inputId,
+ name,
+ value,
+ placeholder,
+ required,
+ disabled,
+ onChange: handleChange,
+ 'aria-invalid': error ? (true as const) : undefined,
+ 'aria-describedby': error ? `${inputId}-error` : undefined,
+ };
+
+ return (
+
+
+ {label}
+ {required && (
+
+ *
+
+ )}
+
+
+ {type === 'textarea' ? (
+
+ ) : (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
+
+export default Input;
diff --git a/frontend/src/components/NeighborhoodCard.test.tsx b/frontend/src/components/NeighborhoodCard.test.tsx
new file mode 100644
index 0000000..f1d9e2e
--- /dev/null
+++ b/frontend/src/components/NeighborhoodCard.test.tsx
@@ -0,0 +1,164 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import NeighborhoodCard from './NeighborhoodCard';
+import type { Neighborhood } from '../types/models';
+
+const mockNeighborhood: Neighborhood = {
+ id: 'nb-1',
+ name: 'Downtown Arts District',
+ slug: 'downtown-arts-district',
+ city: 'Austin',
+ state: 'TX',
+ description: 'A vibrant neighborhood full of galleries and restaurants.',
+ image:
+ 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=800&h=600&fit=crop',
+ averagePrice: 525000,
+ propertyCount: 42,
+ walkScore: 92,
+ transitScore: 78,
+ highlights: ['Art Galleries', 'Fine Dining', 'Nightlife'],
+};
+
+describe('NeighborhoodCard', () => {
+ it('renders without crashing', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card')).toBeTruthy();
+ });
+
+ it('displays the neighborhood name', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card-name')).toHaveTextContent(
+ 'Downtown Arts District'
+ );
+ });
+
+ it('displays the city and state', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card-location')).toHaveTextContent(
+ 'Austin, TX'
+ );
+ });
+
+ it('displays the formatted average price', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card-price')).toHaveTextContent(
+ 'Avg. $525K'
+ );
+ });
+
+ it('formats price in millions correctly', () => {
+ const expensiveNeighborhood: Neighborhood = {
+ ...mockNeighborhood,
+ averagePrice: 1_500_000,
+ };
+ render( );
+ expect(screen.getByTestId('neighborhood-card-price')).toHaveTextContent(
+ 'Avg. $1.5M'
+ );
+ });
+
+ it('formats exact million price without decimal', () => {
+ const millionNeighborhood: Neighborhood = {
+ ...mockNeighborhood,
+ averagePrice: 2_000_000,
+ };
+ render( );
+ expect(screen.getByTestId('neighborhood-card-price')).toHaveTextContent(
+ 'Avg. $2M'
+ );
+ });
+
+ it('displays the property count with correct pluralization', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card-count')).toHaveTextContent(
+ '42 Properties'
+ );
+ });
+
+ it('uses singular "Property" for count of 1', () => {
+ const singlePropertyNeighborhood: Neighborhood = {
+ ...mockNeighborhood,
+ propertyCount: 1,
+ };
+ render(
+
+ );
+ expect(screen.getByTestId('neighborhood-card-count')).toHaveTextContent(
+ '1 Property'
+ );
+ });
+
+ it('sets the background image from the neighborhood image prop', () => {
+ render( );
+ const imageDiv = screen.getByTestId('neighborhood-card-image');
+ expect(imageDiv.style.backgroundImage).toBe(
+ `url(${mockNeighborhood.image})`
+ );
+ });
+
+ it('calls onClick with the neighborhood when clicked', async () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByTestId('neighborhood-card'));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(mockNeighborhood);
+ });
+
+ it('calls onClick when Enter key is pressed', () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+
+ const card = screen.getByTestId('neighborhood-card');
+ fireEvent.keyDown(card, { key: 'Enter' });
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(mockNeighborhood);
+ });
+
+ it('calls onClick when Space key is pressed', () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+
+ const card = screen.getByTestId('neighborhood-card');
+ fireEvent.keyDown(card, { key: ' ' });
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not crash when onClick is not provided', async () => {
+ render( );
+ // Should not throw when clicked without onClick handler
+ await userEvent.click(screen.getByTestId('neighborhood-card'));
+ });
+
+ it('has proper aria-label for accessibility', () => {
+ render( );
+ const card = screen.getByTestId('neighborhood-card');
+ expect(card).toHaveAttribute(
+ 'aria-label',
+ 'View properties in Downtown Arts District'
+ );
+ });
+
+ it('has role="button" and is focusable', () => {
+ render( );
+ const card = screen.getByTestId('neighborhood-card');
+ expect(card).toHaveAttribute('role', 'button');
+ expect(card).toHaveAttribute('tabindex', '0');
+ });
+});
diff --git a/frontend/src/components/NeighborhoodCard.tsx b/frontend/src/components/NeighborhoodCard.tsx
new file mode 100644
index 0000000..b5feefc
--- /dev/null
+++ b/frontend/src/components/NeighborhoodCard.tsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import type { Neighborhood } from '../types/models';
+
+export interface NeighborhoodCardProps {
+ /** The neighborhood data to display */
+ neighborhood: Neighborhood;
+ /** Callback invoked when the card is clicked */
+ onClick?: (neighborhood: Neighborhood) => void;
+}
+
+function formatPrice(price: number): string {
+ if (price >= 1_000_000) {
+ const millions = price / 1_000_000;
+ return `$${millions % 1 === 0 ? millions.toFixed(0) : millions.toFixed(1)}M`;
+ }
+ if (price >= 1_000) {
+ return `$${(price / 1_000).toFixed(0)}K`;
+ }
+ return `$${price.toLocaleString()}`;
+}
+
+const NeighborhoodCard: React.FC = ({
+ neighborhood,
+ onClick,
+}) => {
+ const handleClick = () => {
+ if (onClick) {
+ onClick(neighborhood);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleClick();
+ }
+ };
+
+ return (
+
+ {/* Background Image */}
+
+
+ {/* Dark Gradient Overlay */}
+
+
+ {/* Content */}
+
+ {/* City & State Badge */}
+
+ {neighborhood.city}, {neighborhood.state}
+
+
+ {/* Neighborhood Name */}
+
+ {neighborhood.name}
+
+
+ {/* Stats Row */}
+
+
+
+
+
+
+ Avg. {formatPrice(neighborhood.averagePrice)}
+
+
+
+
+
+
+
+
+
+
+ {neighborhood.propertyCount} {neighborhood.propertyCount === 1 ? 'Property' : 'Properties'}
+
+
+
+
+ {/* Explore Arrow — visible on hover */}
+
+
Explore neighborhood
+
+
+
+
+
+
+ );
+};
+
+export default NeighborhoodCard;
diff --git a/frontend/src/components/NeighborhoodHighlights.test.tsx b/frontend/src/components/NeighborhoodHighlights.test.tsx
new file mode 100644
index 0000000..8b26176
--- /dev/null
+++ b/frontend/src/components/NeighborhoodHighlights.test.tsx
@@ -0,0 +1,250 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import NeighborhoodHighlights, {
+ NeighborhoodData,
+ NeighborhoodHighlightsProps,
+} from './NeighborhoodHighlights';
+
+const mockNeighborhoods: NeighborhoodData[] = [
+ {
+ id: '1',
+ name: 'Beverly Hills',
+ slug: 'beverly-hills',
+ city: 'Los Angeles',
+ state: 'CA',
+ description: 'Iconic luxury neighborhood known for world-class shopping and stunning estates.',
+ image: 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=800&h=600&fit=crop',
+ averagePrice: 3500000,
+ walkScore: 86,
+ transitScore: 62,
+ highlights: ['Fine Dining', 'Designer Shops', 'Celebrity Estates'],
+ },
+ {
+ id: '2',
+ name: 'Manhattan Beach',
+ slug: 'manhattan-beach',
+ city: 'Los Angeles',
+ state: 'CA',
+ description: 'A coastal gem with pristine beaches and a vibrant downtown scene.',
+ image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800&h=600&fit=crop',
+ averagePrice: 2800000,
+ walkScore: 74,
+ transitScore: 45,
+ highlights: ['Beachfront', 'Top Schools', 'Pier Walk'],
+ },
+ {
+ id: '3',
+ name: 'Bel Air',
+ slug: 'bel-air',
+ city: 'Los Angeles',
+ state: 'CA',
+ description: 'Ultra-exclusive gated community with sprawling estates and panoramic views.',
+ image: 'https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800&h=600&fit=crop',
+ averagePrice: 5200000,
+ walkScore: 32,
+ transitScore: 18,
+ highlights: ['Gated Community', 'Mountain Views', 'Private Estates'],
+ },
+];
+
+describe('NeighborhoodHighlights', () => {
+ it('renders without crashing', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-highlights')).toBeInTheDocument();
+ });
+
+ it('displays the default heading', () => {
+ render( );
+ expect(screen.getByTestId('section-heading')).toHaveTextContent('Explore Neighborhoods');
+ });
+
+ it('displays a custom heading when provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('section-heading')).toHaveTextContent('Top Neighborhoods');
+ });
+
+ it('displays the subheading when provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('section-subheading')).toHaveTextContent(
+ 'Discover the best places to live'
+ );
+ });
+
+ it('does not render subheading when not provided', () => {
+ render( );
+ expect(screen.queryByTestId('section-subheading')).not.toBeInTheDocument();
+ });
+
+ it('renders all neighborhood cards', () => {
+ render( );
+ expect(screen.getByTestId('neighborhood-card-1')).toBeInTheDocument();
+ expect(screen.getByTestId('neighborhood-card-2')).toBeInTheDocument();
+ expect(screen.getByTestId('neighborhood-card-3')).toBeInTheDocument();
+ });
+
+ it('displays neighborhood names', () => {
+ render( );
+ expect(screen.getByText('Beverly Hills')).toBeInTheDocument();
+ expect(screen.getByText('Manhattan Beach')).toBeInTheDocument();
+ expect(screen.getByText('Bel Air')).toBeInTheDocument();
+ });
+
+ it('displays city and state for each neighborhood', () => {
+ render( );
+ const cityStateElements = screen.getAllByText('Los Angeles, CA');
+ expect(cityStateElements.length).toBe(3);
+ });
+
+ it('displays neighborhood descriptions', () => {
+ render( );
+ expect(
+ screen.getByText(/Iconic luxury neighborhood known for world-class/)
+ ).toBeInTheDocument();
+ });
+
+ it('displays formatted average prices', () => {
+ render( );
+ expect(screen.getByText('$3.5M')).toBeInTheDocument();
+ expect(screen.getByText('$2.8M')).toBeInTheDocument();
+ expect(screen.getByText('$5.2M')).toBeInTheDocument();
+ });
+
+ it('displays walk scores and transit scores', () => {
+ render( );
+ expect(screen.getByText('86')).toBeInTheDocument();
+ expect(screen.getByText('62')).toBeInTheDocument();
+ });
+
+ it('displays highlight tags', () => {
+ render( );
+ expect(screen.getByText('Fine Dining')).toBeInTheDocument();
+ expect(screen.getByText('Designer Shops')).toBeInTheDocument();
+ expect(screen.getByText('Celebrity Estates')).toBeInTheDocument();
+ expect(screen.getByText('Beachfront')).toBeInTheDocument();
+ });
+
+ it('limits highlights to 3 per card', () => {
+ const neighborhoodWithManyHighlights: NeighborhoodData[] = [
+ {
+ ...mockNeighborhoods[0],
+ highlights: ['A', 'B', 'C', 'D', 'E'],
+ },
+ ];
+ render(
+
+ );
+ expect(screen.getByText('A')).toBeInTheDocument();
+ expect(screen.getByText('B')).toBeInTheDocument();
+ expect(screen.getByText('C')).toBeInTheDocument();
+ expect(screen.queryByText('D')).not.toBeInTheDocument();
+ expect(screen.queryByText('E')).not.toBeInTheDocument();
+ });
+
+ it('renders images with correct alt text', () => {
+ render( );
+ const img = screen.getByAlt('Beverly Hills neighborhood');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', mockNeighborhoods[0].image);
+ });
+
+ it('shows empty state when no neighborhoods are provided', () => {
+ render( );
+ expect(screen.getByTestId('empty-state')).toHaveTextContent(
+ 'No neighborhoods to display.'
+ );
+ });
+
+ it('does not show empty state when neighborhoods are present', () => {
+ render( );
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ });
+
+ it('calls onNeighborhoodClick when a card is clicked', () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('neighborhood-card-1'));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(mockNeighborhoods[0]);
+ });
+
+ it('calls onNeighborhoodClick on Enter key press', () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+ fireEvent.keyDown(screen.getByTestId('neighborhood-card-2'), {
+ key: 'Enter',
+ });
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(mockNeighborhoods[1]);
+ });
+
+ it('calls onNeighborhoodClick on Space key press', () => {
+ const handleClick = vi.fn();
+ render(
+
+ );
+ fireEvent.keyDown(screen.getByTestId('neighborhood-card-3'), {
+ key: ' ',
+ });
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(mockNeighborhoods[2]);
+ });
+
+ it('does not crash when onNeighborhoodClick is not provided', () => {
+ render( );
+ expect(() => {
+ fireEvent.click(screen.getByTestId('neighborhood-card-1'));
+ }).not.toThrow();
+ });
+
+ it('renders the grid container', () => {
+ render( );
+ const grid = screen.getByTestId('neighborhood-grid');
+ expect(grid).toBeInTheDocument();
+ expect(grid.children.length).toBe(3);
+ });
+
+ it('handles neighborhoods with empty highlights array', () => {
+ const noHighlights: NeighborhoodData[] = [
+ {
+ ...mockNeighborhoods[0],
+ highlights: [],
+ },
+ ];
+ render( );
+ expect(screen.getByTestId('neighborhood-card-1')).toBeInTheDocument();
+ });
+
+ it('formats prices under 1M correctly', () => {
+ const cheapNeighborhood: NeighborhoodData[] = [
+ {
+ ...mockNeighborhoods[0],
+ averagePrice: 750000,
+ },
+ ];
+ render( );
+ expect(screen.getByText('$750K')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/NeighborhoodHighlights.tsx b/frontend/src/components/NeighborhoodHighlights.tsx
new file mode 100644
index 0000000..3320f1a
--- /dev/null
+++ b/frontend/src/components/NeighborhoodHighlights.tsx
@@ -0,0 +1,167 @@
+import React from 'react';
+
+export interface NeighborhoodData {
+ id: string;
+ name: string;
+ slug: string;
+ city: string;
+ state: string;
+ description: string;
+ image: string;
+ averagePrice: number;
+ walkScore: number;
+ transitScore: number;
+ highlights: string[];
+}
+
+export interface NeighborhoodHighlightsProps {
+ neighborhoods: NeighborhoodData[];
+ heading?: string;
+ subheading?: string;
+ onNeighborhoodClick?: (neighborhood: NeighborhoodData) => void;
+}
+
+function formatPrice(price: number): string {
+ if (price >= 1_000_000) {
+ return `$${(price / 1_000_000).toFixed(1)}M`;
+ }
+ return `$${(price / 1_000).toFixed(0)}K`;
+}
+
+const NeighborhoodHighlights: React.FC = ({
+ neighborhoods,
+ heading = 'Explore Neighborhoods',
+ subheading,
+ onNeighborhoodClick,
+}) => {
+ return (
+
+
+ {/* Section Header */}
+
+
+
+
+ Curated Living
+
+
+
+
+ {heading}
+
+ {subheading && (
+
+ {subheading}
+
+ )}
+
+
+ {/* Neighborhood Grid */}
+
+ {neighborhoods.map((neighborhood) => (
+
onNeighborhoodClick?.(neighborhood)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onNeighborhoodClick?.(neighborhood);
+ }
+ }}
+ >
+ {/* Image Container */}
+
+
+
+
+
+ {neighborhood.name}
+
+
+ {neighborhood.city}, {neighborhood.state}
+
+
+
+
+ {/* Content */}
+
+
+ {neighborhood.description}
+
+
+ {/* Stats Row */}
+
+
+
Avg. Price
+
+ {formatPrice(neighborhood.averagePrice)}
+
+
+
+
Walk Score
+
+ {neighborhood.walkScore}
+
+
+
+
Transit
+
+ {neighborhood.transitScore}
+
+
+
+
+ {/* Highlights */}
+ {neighborhood.highlights.length > 0 && (
+
+ {neighborhood.highlights.slice(0, 3).map((highlight) => (
+
+ {highlight}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+ {/* Empty State */}
+ {neighborhoods.length === 0 && (
+
+ No neighborhoods to display.
+
+ )}
+
+
+ );
+};
+
+export default NeighborhoodHighlights;
diff --git a/frontend/src/components/PhotoGallery.test.tsx b/frontend/src/components/PhotoGallery.test.tsx
new file mode 100644
index 0000000..cecc89a
--- /dev/null
+++ b/frontend/src/components/PhotoGallery.test.tsx
@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import PhotoGallery from './PhotoGallery';
+
+const MOCK_IMAGES = [
+ 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+];
+
+describe('PhotoGallery', () => {
+ beforeEach(() => {
+ document.body.style.overflow = '';
+ });
+
+ it('renders without crashing with images', () => {
+ render( );
+ expect(screen.getByTestId('photo-gallery')).toBeDefined();
+ });
+
+ it('renders empty state when no images are provided', () => {
+ render( );
+ expect(screen.getByTestId('photo-gallery-empty')).toBeDefined();
+ expect(screen.getByText('No images available')).toBeDefined();
+ });
+
+ it('displays the first image as the main image by default', () => {
+ render( );
+ const mainImg = screen.getByTestId('main-image') as HTMLImageElement;
+ expect(mainImg.src).toBe(MOCK_IMAGES[0]);
+ expect(mainImg.alt).toBe('Test property 1');
+ });
+
+ it('renders thumbnail strip with correct number of thumbnails', () => {
+ render( );
+ const strip = screen.getByTestId('thumbnail-strip');
+ expect(strip).toBeDefined();
+ MOCK_IMAGES.forEach((_, index) => {
+ expect(screen.getByTestId(`thumbnail-${index}`)).toBeDefined();
+ });
+ });
+
+ it('does not render thumbnail strip for single image', () => {
+ render( );
+ expect(screen.queryByTestId('thumbnail-strip')).toBeNull();
+ });
+
+ it('changes main image when a thumbnail is clicked', () => {
+ render( );
+ const thumbnail2 = screen.getByTestId('thumbnail-2');
+ fireEvent.click(thumbnail2);
+ const mainImg = screen.getByTestId('main-image') as HTMLImageElement;
+ expect(mainImg.src).toBe(MOCK_IMAGES[2]);
+ });
+
+ it('opens lightbox when main image is clicked', () => {
+ render( );
+ expect(screen.queryByTestId('lightbox-overlay')).toBeNull();
+ const mainImageContainer = screen.getByRole('button', { name: /open fullscreen lightbox/i });
+ fireEvent.click(mainImageContainer);
+ expect(screen.getByTestId('lightbox-overlay')).toBeDefined();
+ expect(screen.getByTestId('lightbox-image')).toBeDefined();
+ });
+
+ it('lightbox displays the currently active image', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('thumbnail-1'));
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ const lightboxImg = screen.getByTestId('lightbox-image') as HTMLImageElement;
+ expect(lightboxImg.src).toBe(MOCK_IMAGES[1]);
+ });
+
+ it('navigates to next image in lightbox', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ const nextBtn = screen.getByTestId('lightbox-next');
+ fireEvent.click(nextBtn);
+ const lightboxImg = screen.getByTestId('lightbox-image') as HTMLImageElement;
+ expect(lightboxImg.src).toBe(MOCK_IMAGES[1]);
+ });
+
+ it('navigates to previous image in lightbox', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ const prevBtn = screen.getByTestId('lightbox-prev');
+ fireEvent.click(prevBtn);
+ const lightboxImg = screen.getByTestId('lightbox-image') as HTMLImageElement;
+ expect(lightboxImg.src).toBe(MOCK_IMAGES[MOCK_IMAGES.length - 1]);
+ });
+
+ it('wraps around to first image when clicking next on last image', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('thumbnail-3'));
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ const nextBtn = screen.getByTestId('lightbox-next');
+ fireEvent.click(nextBtn);
+ const lightboxImg = screen.getByTestId('lightbox-image') as HTMLImageElement;
+ expect(lightboxImg.src).toBe(MOCK_IMAGES[0]);
+ });
+
+ it('closes lightbox when close button is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ expect(screen.getByTestId('lightbox-overlay')).toBeDefined();
+ fireEvent.click(screen.getByTestId('lightbox-close'));
+ expect(screen.queryByTestId('lightbox-overlay')).toBeNull();
+ });
+
+ it('closes lightbox when overlay background is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ fireEvent.click(screen.getByTestId('lightbox-overlay'));
+ expect(screen.queryByTestId('lightbox-overlay')).toBeNull();
+ });
+
+ it('closes lightbox on Escape key press', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ expect(screen.getByTestId('lightbox-overlay')).toBeDefined();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByTestId('lightbox-overlay')).toBeNull();
+ });
+
+ it('navigates with arrow keys in lightbox', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
+ const lightboxImg = screen.getByTestId('lightbox-image') as HTMLImageElement;
+ expect(lightboxImg.src).toBe(MOCK_IMAGES[1]);
+ fireEvent.keyDown(document, { key: 'ArrowLeft' });
+ expect((screen.getByTestId('lightbox-image') as HTMLImageElement).src).toBe(MOCK_IMAGES[0]);
+ });
+
+ it('uses default alt text when alt prop is not provided', () => {
+ render( );
+ const mainImg = screen.getByTestId('main-image') as HTMLImageElement;
+ expect(mainImg.alt).toBe('Property photo 1');
+ });
+
+ it('displays image counter text', () => {
+ render( );
+ expect(screen.getByText(`1 / ${MOCK_IMAGES.length}`)).toBeDefined();
+ });
+
+ it('has proper dialog role on lightbox', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /open fullscreen lightbox/i }));
+ expect(screen.getByRole('dialog')).toBeDefined();
+ });
+});
diff --git a/frontend/src/components/PhotoGallery.tsx b/frontend/src/components/PhotoGallery.tsx
new file mode 100644
index 0000000..0372480
--- /dev/null
+++ b/frontend/src/components/PhotoGallery.tsx
@@ -0,0 +1,242 @@
+import React, { useState, useEffect, useCallback } from 'react';
+
+export interface PhotoGalleryProps {
+ images: string[];
+ alt?: string;
+}
+
+const PhotoGallery: React.FC = ({ images, alt = 'Property photo' }) => {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+
+ const openLightbox = useCallback(() => {
+ setLightboxIndex(activeIndex);
+ setLightboxOpen(true);
+ }, [activeIndex]);
+
+ const closeLightbox = useCallback(() => {
+ setLightboxOpen(false);
+ }, []);
+
+ const goToPrev = useCallback(() => {
+ setLightboxIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
+ }, [images.length]);
+
+ const goToNext = useCallback(() => {
+ setLightboxIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
+ }, [images.length]);
+
+ useEffect(() => {
+ if (!lightboxOpen) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') closeLightbox();
+ if (e.key === 'ArrowLeft') goToPrev();
+ if (e.key === 'ArrowRight') goToNext();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [lightboxOpen, closeLightbox, goToPrev, goToNext]);
+
+ useEffect(() => {
+ if (lightboxOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = '';
+ }
+ return () => { document.body.style.overflow = ''; };
+ }, [lightboxOpen]);
+
+ if (!images || images.length === 0) {
+ return (
+
+ No images available
+
+ );
+ }
+
+ return (
+
+ {/* Main Image */}
+
{ if (e.key === 'Enter' || e.key === ' ') openLightbox(); }}
+ style={{
+ width: '100%', height: '500px', borderRadius: '12px',
+ overflow: 'hidden', cursor: 'pointer', position: 'relative',
+ backgroundColor: '#1f2937',
+ }}
+ >
+
+
+ {activeIndex + 1} / {images.length}
+
+
+
+ {/* Thumbnail Strip */}
+ {images.length > 1 && (
+
+ {images.map((src, index) => (
+
setActiveIndex(index)}
+ aria-label={`View image ${index + 1}`}
+ style={{
+ flexShrink: 0, width: '100px', height: '72px',
+ borderRadius: '8px', overflow: 'hidden', cursor: 'pointer',
+ border: activeIndex === index ? '3px solid #2563eb' : '3px solid transparent',
+ opacity: activeIndex === index ? 1 : 0.6,
+ transition: 'opacity 0.3s ease, border-color 0.3s ease',
+ padding: 0, background: 'none',
+ }}
+ >
+
+
+ ))}
+
+ )}
+
+ {/* Lightbox Modal */}
+ {lightboxOpen && (
+
+ {/* Close Button */}
+
{ e.stopPropagation(); closeLightbox(); }}
+ aria-label="Close lightbox"
+ style={{
+ position: 'absolute', top: '20px', right: '20px',
+ background: 'rgba(255,255,255,0.15)', border: 'none',
+ color: '#fff', fontSize: '28px', width: '48px', height: '48px',
+ borderRadius: '50%', cursor: 'pointer', display: 'flex',
+ alignItems: 'center', justifyContent: 'center',
+ transition: 'background 0.2s ease',
+ }}
+ onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'rgba(255,255,255,0.3)'; }}
+ onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'rgba(255,255,255,0.15)'; }}
+ >
+ ✕
+
+
+ {/* Prev Button */}
+ {images.length > 1 && (
+
{ e.stopPropagation(); goToPrev(); }}
+ aria-label="Previous image"
+ style={{
+ position: 'absolute', left: '20px', top: '50%',
+ transform: 'translateY(-50%)',
+ background: 'rgba(255,255,255,0.15)', border: 'none',
+ color: '#fff', fontSize: '24px', width: '48px', height: '48px',
+ borderRadius: '50%', cursor: 'pointer', display: 'flex',
+ alignItems: 'center', justifyContent: 'center',
+ transition: 'background 0.2s ease',
+ }}
+ >
+ ‹
+
+ )}
+
+ {/* Lightbox Image */}
+
e.stopPropagation()}
+ style={{
+ maxWidth: '90vw', maxHeight: '85vh', objectFit: 'contain',
+ borderRadius: '8px',
+ transition: 'opacity 0.3s ease-in-out',
+ }}
+ />
+
+ {/* Next Button */}
+ {images.length > 1 && (
+
{ e.stopPropagation(); goToNext(); }}
+ aria-label="Next image"
+ style={{
+ position: 'absolute', right: '20px', top: '50%',
+ transform: 'translateY(-50%)',
+ background: 'rgba(255,255,255,0.15)', border: 'none',
+ color: '#fff', fontSize: '24px', width: '48px', height: '48px',
+ borderRadius: '50%', cursor: 'pointer', display: 'flex',
+ alignItems: 'center', justifyContent: 'center',
+ transition: 'background 0.2s ease',
+ }}
+ >
+ ›
+
+ )}
+
+ {/* Counter */}
+
+ {lightboxIndex + 1} / {images.length}
+
+
+ )}
+
+ );
+};
+
+export default PhotoGallery;
diff --git a/frontend/src/components/PropertyCard.test.tsx b/frontend/src/components/PropertyCard.test.tsx
new file mode 100644
index 0000000..9899be3
--- /dev/null
+++ b/frontend/src/components/PropertyCard.test.tsx
@@ -0,0 +1,187 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import PropertyCard, { PropertyCardProps } from './PropertyCard';
+import { Property } from '../types/models';
+
+const mockAgent = {
+ id: 'agent-1',
+ name: 'Jane Smith',
+ title: 'Senior Agent',
+ phone: '555-0100',
+ email: 'jane@example.com',
+ photo: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop',
+ bio: 'Experienced real estate agent.',
+ specialties: ['Residential', 'Luxury'],
+ propertiesCount: 42,
+ rating: 4.9,
+ socialLinks: {},
+};
+
+const createMockProperty = (overrides: Partial = {}): Property => ({
+ id: 'prop-1',
+ title: 'Modern Luxury Villa',
+ slug: 'modern-luxury-villa',
+ price: 1250000,
+ address: '123 Elm Street',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78701',
+ propertyType: 'house',
+ bedrooms: 4,
+ bathrooms: 3,
+ squareFeet: 2800,
+ lotSize: 0.35,
+ yearBuilt: 2021,
+ description: 'A beautiful modern villa.',
+ features: ['Pool', 'Garage', 'Smart Home'],
+ images: [
+ 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ ],
+ agent: mockAgent,
+ neighborhood: 'downtown',
+ listingDate: '2024-01-15',
+ status: 'for-sale',
+ isFeatured: false,
+ ...overrides,
+});
+
+const renderCard = (props: Partial = {}) => {
+ const defaultProps: PropertyCardProps = {
+ property: createMockProperty(),
+ ...props,
+ };
+ return render( );
+};
+
+describe('PropertyCard', () => {
+ it('renders without crashing', () => {
+ renderCard();
+ expect(screen.getByTestId('property-card')).toBeInTheDocument();
+ });
+
+ it('displays the formatted price with commas', () => {
+ renderCard({ property: createMockProperty({ price: 1250000 }) });
+ expect(screen.getByTestId('property-price')).toHaveTextContent('$1,250,000');
+ });
+
+ it('displays the full address', () => {
+ renderCard();
+ expect(screen.getByTestId('property-address')).toHaveTextContent(
+ '123 Elm Street, Austin, TX 78701'
+ );
+ });
+
+ it('displays bedrooms count', () => {
+ renderCard({ property: createMockProperty({ bedrooms: 4 }) });
+ expect(screen.getByTestId('property-beds')).toHaveTextContent('4');
+ });
+
+ it('displays bathrooms count', () => {
+ renderCard({ property: createMockProperty({ bathrooms: 3 }) });
+ expect(screen.getByTestId('property-baths')).toHaveTextContent('3');
+ });
+
+ it('displays formatted square feet', () => {
+ renderCard({ property: createMockProperty({ squareFeet: 2800 }) });
+ expect(screen.getByTestId('property-sqft')).toHaveTextContent('2,800');
+ });
+
+ it('shows Featured badge when isFeatured is true', () => {
+ renderCard({ property: createMockProperty({ isFeatured: true }) });
+ expect(screen.getByTestId('featured-badge')).toBeInTheDocument();
+ expect(screen.getByTestId('featured-badge')).toHaveTextContent('Featured');
+ });
+
+ it('does not show Featured badge when isFeatured is false', () => {
+ renderCard({ property: createMockProperty({ isFeatured: false }) });
+ expect(screen.queryByTestId('featured-badge')).not.toBeInTheDocument();
+ });
+
+ it('displays the correct status badge for "for-sale"', () => {
+ renderCard({ property: createMockProperty({ status: 'for-sale' }) });
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('For Sale');
+ });
+
+ it('displays the correct status badge for "pending"', () => {
+ renderCard({ property: createMockProperty({ status: 'pending' }) });
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('Pending');
+ });
+
+ it('displays the correct status badge for "sold"', () => {
+ renderCard({ property: createMockProperty({ status: 'sold' }) });
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('Sold');
+ });
+
+ it('renders the primary image with object-cover and correct alt text', () => {
+ renderCard();
+ const img = screen.getByAlt('Modern Luxury Villa');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute(
+ 'src',
+ 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop'
+ );
+ expect(img.className).toContain('object-cover');
+ });
+
+ it('calls onViewDetail with the slug when clicked', () => {
+ const handleViewDetail = vi.fn();
+ renderCard({
+ property: createMockProperty({ slug: 'modern-luxury-villa' }),
+ onViewDetail: handleViewDetail,
+ });
+ fireEvent.click(screen.getByTestId('property-card'));
+ expect(handleViewDetail).toHaveBeenCalledTimes(1);
+ expect(handleViewDetail).toHaveBeenCalledWith('modern-luxury-villa');
+ });
+
+ it('calls onViewDetail when Enter key is pressed', () => {
+ const handleViewDetail = vi.fn();
+ renderCard({
+ property: createMockProperty({ slug: 'test-property' }),
+ onViewDetail: handleViewDetail,
+ });
+ fireEvent.keyDown(screen.getByTestId('property-card'), { key: 'Enter' });
+ expect(handleViewDetail).toHaveBeenCalledTimes(1);
+ expect(handleViewDetail).toHaveBeenCalledWith('test-property');
+ });
+
+ it('does not crash when onViewDetail is not provided', () => {
+ renderCard({ onViewDetail: undefined });
+ expect(() => {
+ fireEvent.click(screen.getByTestId('property-card'));
+ }).not.toThrow();
+ });
+
+ it('handles property with no images gracefully', () => {
+ renderCard({ property: createMockProperty({ images: [] }) });
+ expect(screen.getByTestId('property-card')).toBeInTheDocument();
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ renderCard({ className: 'my-custom-class' });
+ expect(screen.getByTestId('property-card').className).toContain('my-custom-class');
+ });
+
+ it('has proper accessibility attributes', () => {
+ renderCard();
+ const card = screen.getByTestId('property-card');
+ expect(card).toHaveAttribute('role', 'link');
+ expect(card).toHaveAttribute('tabindex', '0');
+ expect(card).toHaveAttribute(
+ 'aria-label',
+ 'View details for Modern Luxury Villa'
+ );
+ });
+
+ it('formats large prices correctly', () => {
+ renderCard({ property: createMockProperty({ price: 15999999 }) });
+ expect(screen.getByTestId('property-price')).toHaveTextContent('$15,999,999');
+ });
+
+ it('formats large square footage correctly', () => {
+ renderCard({ property: createMockProperty({ squareFeet: 12500 }) });
+ expect(screen.getByTestId('property-sqft')).toHaveTextContent('12,500');
+ });
+});
diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx
new file mode 100644
index 0000000..b4c10cf
--- /dev/null
+++ b/frontend/src/components/PropertyCard.tsx
@@ -0,0 +1,178 @@
+import React from 'react';
+import { Bed, Bath, Square } from 'lucide-react';
+import { Property } from '../types/models';
+
+export interface PropertyCardProps {
+ /** The property data to display */
+ property: Property;
+ /** Optional click handler for navigating to the property detail */
+ onViewDetail?: (slug: string) => void;
+ /** Optional className for the outer container */
+ className?: string;
+}
+
+function formatPrice(price: number): string {
+ return price.toLocaleString('en-US');
+}
+
+function formatSquareFeet(sqft: number): string {
+ return sqft.toLocaleString('en-US');
+}
+
+const PropertyCard: React.FC = ({
+ property,
+ onViewDetail,
+ className = '',
+}) => {
+ const {
+ title,
+ slug,
+ price,
+ address,
+ city,
+ state,
+ zipCode,
+ bedrooms,
+ bathrooms,
+ squareFeet,
+ images,
+ status,
+ isFeatured,
+ } = property;
+
+ const primaryImage = images.length > 0 ? images[0] : '';
+ const fullAddress = `${address}, ${city}, ${state} ${zipCode}`;
+
+ const handleClick = () => {
+ if (onViewDetail) {
+ onViewDetail(slug);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleClick();
+ }
+ };
+
+ const statusLabel: Record = {
+ 'for-sale': 'For Sale',
+ pending: 'Pending',
+ sold: 'Sold',
+ };
+
+ return (
+
+ {/* Image Container */}
+
+ {primaryImage && (
+
+ )}
+
+ {/* Featured Badge */}
+ {isFeatured && (
+
+ Featured
+
+ )}
+
+ {/* Status Badge */}
+
+ {statusLabel[status] ?? status}
+
+
+
+ {/* Content */}
+
+ {/* Price */}
+
+ ${formatPrice(price)}
+
+
+ {/* Address */}
+
+ {fullAddress}
+
+
+ {/* Divider */}
+
+
+ {/* Info Row */}
+
+
+
+ {bedrooms}
+ bedrooms
+
+
+
+
+ {bathrooms}
+ bathrooms
+
+
+
+
+
+ {formatSquareFeet(squareFeet)}
+
+ sqft
+
+
+
+
+ );
+};
+
+export default PropertyCard;
diff --git a/frontend/src/components/PropertyGrid.test.tsx b/frontend/src/components/PropertyGrid.test.tsx
new file mode 100644
index 0000000..14c24ff
--- /dev/null
+++ b/frontend/src/components/PropertyGrid.test.tsx
@@ -0,0 +1,201 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import PropertyGrid, { PropertyGridItem } from './PropertyGrid';
+
+// Mock PropertyCard so we isolate PropertyGrid logic
+vi.mock('./PropertyCard', () => ({
+ default: (props: {
+ id: string;
+ title: string;
+ price: number;
+ address: string;
+ city: string;
+ state: string;
+ zipCode: string;
+ propertyType: string;
+ bedrooms: number;
+ bathrooms: number;
+ squareFeet: number;
+ image: string;
+ status: string;
+ onClick?: () => void;
+ }) => (
+
+ {props.title}
+ {props.price}
+
+ ),
+}));
+
+const createMockProperty = (overrides: Partial = {}): PropertyGridItem => ({
+ id: '1',
+ title: 'Beautiful Family Home',
+ slug: 'beautiful-family-home',
+ price: 450000,
+ address: '123 Main St',
+ city: 'Springfield',
+ state: 'IL',
+ zipCode: '62701',
+ propertyType: 'house',
+ bedrooms: 4,
+ bathrooms: 3,
+ squareFeet: 2500,
+ lotSize: '0.5 acres',
+ yearBuilt: 2010,
+ description: 'A wonderful home for the whole family.',
+ features: ['Garage', 'Pool', 'Garden'],
+ images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop'],
+ listingDate: '2024-01-15',
+ status: 'for-sale',
+ ...overrides,
+});
+
+const sampleProperties: PropertyGridItem[] = [
+ createMockProperty({ id: '1', title: 'Home One', price: 300000 }),
+ createMockProperty({ id: '2', title: 'Home Two', price: 450000 }),
+ createMockProperty({ id: '3', title: 'Home Three', price: 600000 }),
+];
+
+describe('PropertyGrid', () => {
+ it('renders without crashing', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('property-grid')).toBeInTheDocument();
+ });
+
+ it('displays the section title when provided', () => {
+ render(
+
+ );
+ expect(screen.getByText('Featured Listings')).toBeInTheDocument();
+ expect(screen.getByTestId('property-grid-header')).toBeInTheDocument();
+ });
+
+ it('renders the decorative underline when title is provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('property-grid-underline')).toBeInTheDocument();
+ });
+
+ it('does not render header when title is not provided', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('property-grid-header')).not.toBeInTheDocument();
+ });
+
+ it('shows emptyMessage when properties array is empty', () => {
+ const emptyMsg = 'No properties match your criteria.';
+ render( );
+ expect(screen.getByTestId('property-grid-empty')).toBeInTheDocument();
+ expect(screen.getByText(emptyMsg)).toBeInTheDocument();
+ });
+
+ it('does not show emptyMessage when properties exist', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('property-grid-empty')).not.toBeInTheDocument();
+ });
+
+ it('renders the correct number of property cards', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('property-card-1')).toBeInTheDocument();
+ expect(screen.getByTestId('property-card-2')).toBeInTheDocument();
+ expect(screen.getByTestId('property-card-3')).toBeInTheDocument();
+ });
+
+ it('renders the grid container with correct test id when properties exist', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('property-grid-list')).toBeInTheDocument();
+ });
+
+ it('calls onPropertyClick when a property card is clicked', async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+
+ render(
+
+ );
+
+ await user.click(screen.getByTestId('property-card-2'));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ expect(handleClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: '2', title: 'Home Two' })
+ );
+ });
+
+ it('does not crash when onPropertyClick is not provided', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Should not throw
+ await user.click(screen.getByTestId('property-card-1'));
+ });
+
+ it('passes the first image from images array to PropertyCard', () => {
+ const property = createMockProperty({
+ id: 'img-test',
+ images: [
+ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop',
+ ],
+ });
+
+ render(
+
+ );
+
+ expect(screen.getByTestId('property-card-img-test')).toBeInTheDocument();
+ });
+
+ it('handles a property with an empty images array gracefully', () => {
+ const property = createMockProperty({ id: 'no-img', images: [] });
+
+ render(
+
+ );
+
+ expect(screen.getByTestId('property-card-no-img')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/PropertyGrid.tsx b/frontend/src/components/PropertyGrid.tsx
new file mode 100644
index 0000000..b8cfa89
--- /dev/null
+++ b/frontend/src/components/PropertyGrid.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropertyCard from './PropertyCard';
+
+export interface PropertyGridItem {
+ id: string;
+ title: string;
+ slug: string;
+ price: number;
+ address: string;
+ city: string;
+ state: string;
+ zipCode: string;
+ propertyType: 'house' | 'condo' | 'townhouse' | 'apartment' | 'land';
+ bedrooms: number;
+ bathrooms: number;
+ squareFeet: number;
+ lotSize?: string;
+ yearBuilt?: number;
+ description: string;
+ features: string[];
+ images: string[];
+ listingDate: string;
+ status: 'for-sale' | 'pending' | 'sold';
+}
+
+export interface PropertyGridProps {
+ /** Array of property objects to display */
+ properties: PropertyGridItem[];
+ /** Optional section heading displayed above the grid */
+ title?: string;
+ /** Message displayed when the properties array is empty */
+ emptyMessage: string;
+ /** Optional callback when a property card is clicked */
+ onPropertyClick?: (property: PropertyGridItem) => void;
+}
+
+const PropertyGrid: React.FC = ({
+ properties,
+ title,
+ emptyMessage,
+ onPropertyClick,
+}) => {
+ return (
+
+ {title && (
+
+ )}
+
+ {properties.length === 0 ? (
+
+ ) : (
+
+ {properties.map((property) => (
+
onPropertyClick?.(property)}
+ />
+ ))}
+
+ )}
+
+ );
+};
+
+export default PropertyGrid;
diff --git a/frontend/src/components/SearchFilterBar.test.tsx b/frontend/src/components/SearchFilterBar.test.tsx
new file mode 100644
index 0000000..037abc0
--- /dev/null
+++ b/frontend/src/components/SearchFilterBar.test.tsx
@@ -0,0 +1,282 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import SearchFilterBar, { SearchFilters } from './SearchFilterBar';
+
+const defaultFilters: SearchFilters = {
+ location: '',
+ propertyType: '',
+ minPrice: '',
+ maxPrice: '',
+ beds: '',
+ baths: '',
+};
+
+describe('SearchFilterBar', () => {
+ it('renders without crashing', () => {
+ const { container } = render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+ expect(container).toBeTruthy();
+ });
+
+ it('renders all filter controls', () => {
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ expect(screen.getByTestId('filter-location')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-property-type')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-min-price')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-max-price')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-beds')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-baths')).toBeInTheDocument();
+ expect(screen.getByTestId('search-button')).toBeInTheDocument();
+ });
+
+ it('displays the current filter values', () => {
+ const filters: SearchFilters = {
+ location: 'Austin, TX',
+ propertyType: 'house',
+ minPrice: '200000',
+ maxPrice: '500000',
+ beds: '3',
+ baths: '2',
+ };
+
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ expect(screen.getByTestId('filter-location')).toHaveValue('Austin, TX');
+ expect(screen.getByTestId('filter-property-type')).toHaveValue('house');
+ expect(screen.getByTestId('filter-min-price')).toHaveValue('200000');
+ expect(screen.getByTestId('filter-max-price')).toHaveValue('500000');
+ expect(screen.getByTestId('filter-beds')).toHaveValue('3');
+ expect(screen.getByTestId('filter-baths')).toHaveValue('2');
+ });
+
+ it('calls onFilterChange when location input changes', async () => {
+ const onFilterChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ {}}
+ />,
+ );
+
+ const locationInput = screen.getByTestId('filter-location');
+ await user.type(locationInput, 'D');
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ location: 'D',
+ });
+ });
+
+ it('calls onFilterChange when property type changes', () => {
+ const onFilterChange = vi.fn();
+
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByTestId('filter-property-type'), {
+ target: { value: 'condo' },
+ });
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ propertyType: 'condo',
+ });
+ });
+
+ it('calls onFilterChange when min price changes', () => {
+ const onFilterChange = vi.fn();
+
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByTestId('filter-min-price'), {
+ target: { value: '300000' },
+ });
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ minPrice: '300000',
+ });
+ });
+
+ it('calls onFilterChange when max price changes', () => {
+ const onFilterChange = vi.fn();
+
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByTestId('filter-max-price'), {
+ target: { value: '750000' },
+ });
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ maxPrice: '750000',
+ });
+ });
+
+ it('calls onFilterChange when beds changes', () => {
+ const onFilterChange = vi.fn();
+
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByTestId('filter-beds'), {
+ target: { value: '4' },
+ });
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ beds: '4',
+ });
+ });
+
+ it('calls onFilterChange when baths changes', () => {
+ const onFilterChange = vi.fn();
+
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByTestId('filter-baths'), {
+ target: { value: '2' },
+ });
+
+ expect(onFilterChange).toHaveBeenCalledWith({
+ ...defaultFilters,
+ baths: '2',
+ });
+ });
+
+ it('calls onSearch with current filters when form is submitted', () => {
+ const onSearch = vi.fn();
+ const filters: SearchFilters = {
+ location: 'Denver',
+ propertyType: 'apartment',
+ minPrice: '100000',
+ maxPrice: '500000',
+ beds: '2',
+ baths: '1',
+ };
+
+ render(
+ {}}
+ onSearch={onSearch}
+ />,
+ );
+
+ fireEvent.click(screen.getByTestId('search-button'));
+
+ expect(onSearch).toHaveBeenCalledTimes(1);
+ expect(onSearch).toHaveBeenCalledWith(filters);
+ });
+
+ it('renders all property type options', () => {
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ const select = screen.getByTestId('filter-property-type');
+ const options = select.querySelectorAll('option');
+ const labels = Array.from(options).map((o) => o.textContent);
+
+ expect(labels).toEqual(['All', 'House', 'Apartment', 'Condo', 'Townhouse']);
+ });
+
+ it('renders bed/bath options with Any and numeric choices', () => {
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ const bedsSelect = screen.getByTestId('filter-beds');
+ const bedsOptions = bedsSelect.querySelectorAll('option');
+ const bedsLabels = Array.from(bedsOptions).map((o) => o.textContent);
+
+ expect(bedsLabels).toEqual(['Any', '1+', '2+', '3+', '4+', '5+']);
+ });
+
+ it('renders the search button with correct text', () => {
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ expect(screen.getByTestId('search-button')).toHaveTextContent('Search');
+ });
+
+ it('has accessible labels for all inputs', () => {
+ render(
+ {}}
+ onSearch={() => {}}
+ />,
+ );
+
+ expect(screen.getByLabelText('Location')).toBeInTheDocument();
+ expect(screen.getByLabelText('Type')).toBeInTheDocument();
+ expect(screen.getByLabelText('Min Price')).toBeInTheDocument();
+ expect(screen.getByLabelText('Max Price')).toBeInTheDocument();
+ expect(screen.getByLabelText('Beds')).toBeInTheDocument();
+ expect(screen.getByLabelText('Baths')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/SearchFilterBar.tsx b/frontend/src/components/SearchFilterBar.tsx
new file mode 100644
index 0000000..decd77b
--- /dev/null
+++ b/frontend/src/components/SearchFilterBar.tsx
@@ -0,0 +1,212 @@
+import React from 'react';
+
+export interface SearchFilters {
+ location: string;
+ propertyType: string;
+ minPrice: string;
+ maxPrice: string;
+ beds: string;
+ baths: string;
+}
+
+export interface SearchFilterBarProps {
+ filters: SearchFilters;
+ onFilterChange: (filters: SearchFilters) => void;
+ onSearch: (filters: SearchFilters) => void;
+}
+
+const PROPERTY_TYPES = [
+ { value: '', label: 'All' },
+ { value: 'house', label: 'House' },
+ { value: 'apartment', label: 'Apartment' },
+ { value: 'condo', label: 'Condo' },
+ { value: 'townhouse', label: 'Townhouse' },
+];
+
+const PRICE_OPTIONS = [
+ { value: '', label: 'No Min' },
+ { value: '50000', label: '$50,000' },
+ { value: '100000', label: '$100,000' },
+ { value: '200000', label: '$200,000' },
+ { value: '300000', label: '$300,000' },
+ { value: '400000', label: '$400,000' },
+ { value: '500000', label: '$500,000' },
+ { value: '750000', label: '$750,000' },
+ { value: '1000000', label: '$1,000,000' },
+ { value: '1500000', label: '$1,500,000' },
+ { value: '2000000', label: '$2,000,000' },
+];
+
+const MAX_PRICE_OPTIONS = [
+ { value: '', label: 'No Max' },
+ ...PRICE_OPTIONS.slice(1),
+];
+
+const BED_BATH_OPTIONS = [
+ { value: '', label: 'Any' },
+ { value: '1', label: '1+' },
+ { value: '2', label: '2+' },
+ { value: '3', label: '3+' },
+ { value: '4', label: '4+' },
+ { value: '5', label: '5+' },
+];
+
+const selectClass =
+ 'w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500';
+
+const SearchFilterBar: React.FC = ({
+ filters,
+ onFilterChange,
+ onSearch,
+}) => {
+ const handleChange = (
+ field: keyof SearchFilters,
+ value: string,
+ ) => {
+ onFilterChange({ ...filters, [field]: value });
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSearch(filters);
+ };
+
+ return (
+
+ );
+};
+
+export default SearchFilterBar;
diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx
new file mode 100644
index 0000000..019f197
--- /dev/null
+++ b/frontend/src/pages/ContactPage.tsx
@@ -0,0 +1,324 @@
+import React, { useState } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+import type { ContactFormData, PreferredContact } from "../types/models";
+
+const HERO_IMAGE =
+ "https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=1600&h=400&fit=crop";
+
+const AGENT = {
+ name: "Sarah Mitchell",
+ title: "Senior Real Estate Agent",
+ phone: "(555) 234-5678",
+ email: "sarah@premierrealty.com",
+ photo:
+ "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop",
+ bio: "With over 12 years of experience in residential real estate, Sarah is dedicated to helping clients find their perfect home. She specializes in luxury properties and first-time homebuyers alike.",
+ specialties: ["Luxury Homes", "First-Time Buyers", "Investment Properties"],
+ rating: 4.9,
+};
+
+const OFFICE = {
+ address: "742 Evergreen Terrace, Suite 200",
+ city: "Springfield, IL 62704",
+ phone: "(555) 123-4567",
+ email: "info@premierrealty.com",
+ hours: [
+ { days: "Monday – Friday", time: "9:00 AM – 6:00 PM" },
+ { days: "Saturday", time: "10:00 AM – 4:00 PM" },
+ { days: "Sunday", time: "Closed" },
+ ],
+};
+
+const ContactPage: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const propertyId = searchParams.get("propertyId") ?? undefined;
+
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ phone: "",
+ message: "",
+ propertyId,
+ preferredContact: "email",
+ });
+
+ const [submitted, setSubmitted] = useState(false);
+
+ const handleChange = (
+ e: React.ChangeEvent
+ ): void => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent): void => {
+ e.preventDefault();
+ setSubmitted(true);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Hero Banner */}
+
+
+
+
+ Get In Touch
+
+
+ We'd love to hear from you. Reach out with any questions about properties, buying, or selling.
+
+
+
+
+ {/* Main Content */}
+
+ {submitted ? (
+
+
+
+
Thank You!
+
+ Your message has been sent. We'll get back to you shortly.
+
+
+ Back to Home
+
+
+
+ ) : (
+
+ {/* Left Column — Contact Form (2/3 width on desktop) */}
+
+
+
+ {/* Embedded Map Placeholder */}
+
+
+
+
+
+
+
742 Evergreen Terrace, Springfield, IL 62704
+
Interactive map would be displayed here
+
+
+
+
+ {/* Right Column — Office Info + Agent */}
+
+ {/* Office Location Card */}
+
+
Our Office
+
+
+
+
+
+
+
+
{OFFICE.address}
+
{OFFICE.city}
+
+
+
+
+
+
+
+
+
Office Hours
+
+ {OFFICE.hours.map((h) => (
+
+ {h.days}
+ {h.time}
+
+ ))}
+
+
+
+
+ {/* Agent Profile Section */}
+
+
Your Agent
+
+
+
+
{AGENT.name}
+
{AGENT.title}
+
+
+
+
{AGENT.bio}
+
+ {AGENT.specialties.map((s) => (
+ {s}
+ ))}
+
+
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
Premier Realty
+
Helping you find the perfect place to call home since 2010.
+
+
+
Quick Links
+
+ Home
+ Properties
+ Neighborhoods
+ Contact
+
+
+
+
Contact
+
+ {OFFICE.address}
+ {OFFICE.city}
+ {OFFICE.phone}
+ {OFFICE.email}
+
+
+
+
Office Hours
+
+ {OFFICE.hours.map((h) => (
+ {h.days}: {h.time}
+ ))}
+
+
+
+
+
© {new Date().getFullYear()} Premier Realty. All rights reserved.
+
+
+
+
+ );
+};
+
+export default ContactPage;
diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts
new file mode 100644
index 0000000..792f2bb
--- /dev/null
+++ b/frontend/src/types/models.ts
@@ -0,0 +1,72 @@
+export type PropertyType = 'house' | 'condo' | 'townhouse' | 'apartment' | 'land';
+
+export type PropertyStatus = 'for-sale' | 'pending' | 'sold';
+
+export type PreferredContact = 'email' | 'phone' | 'either';
+
+export interface Agent {
+ id: string;
+ name: string;
+ title: string;
+ phone: string;
+ email: string;
+ photo: string;
+ bio: string;
+ specialties: string[];
+ propertiesCount: number;
+ rating: number;
+ socialLinks: {
+ linkedin?: string;
+ twitter?: string;
+ facebook?: string;
+ };
+}
+
+export interface Neighborhood {
+ id: string;
+ name: string;
+ slug: string;
+ city: string;
+ state: string;
+ description: string;
+ image: string;
+ averagePrice: number;
+ walkScore: number;
+ transitScore: number;
+ highlights: string[];
+ featuredProperties: string[];
+}
+
+export interface Property {
+ id: string;
+ title: string;
+ slug: string;
+ price: number;
+ address: string;
+ city: string;
+ state: string;
+ zipCode: string;
+ propertyType: PropertyType;
+ bedrooms: number;
+ bathrooms: number;
+ squareFeet: number;
+ lotSize: number;
+ yearBuilt: number;
+ description: string;
+ features: string[];
+ images: string[];
+ agent: Agent;
+ neighborhood: string;
+ listingDate: string;
+ status: PropertyStatus;
+ isFeatured?: boolean;
+}
+
+export interface ContactFormData {
+ name: string;
+ email: string;
+ phone: string;
+ message: string;
+ propertyId?: string;
+ preferredContact: PreferredContact;
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d7d781b
--- /dev/null
+++ b/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Real Estate — Find Your Dream Home
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..255bbfe
--- /dev/null
+++ b/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "real-estate-website",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "description": "A modern real estate listing website built with React, Vite, and TypeScript",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "lucide-react": "^0.468.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tailwindcss": "^4.0.0",
+ "typescript": "~5.6.0",
+ "vite": "^6.0.0",
+ "vitest": "^2.1.0"
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..9c903e8
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Routes, Route } from 'react-router-dom';
+import HomePage from './pages/HomePage';
+import PropertyDetailPage from './pages/PropertyDetailPage';
+import ContactPage from './pages/ContactPage';
+import NotFoundPage from './pages/NotFoundPage';
+import ScrollToTop from './components/ScrollToTop';
+
+/**
+ * Root application component.
+ *
+ * Defines the top-level route structure for the real estate website.
+ * Each route maps a URL path to a page-level component.
+ * Includes a ScrollToTop utility that scrolls to the top of the page
+ * on every route change, and a catch-all 404 route for unknown paths.
+ */
+const App: React.FC = () => {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+};
+
+export default App;
diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx
new file mode 100644
index 0000000..f8514c8
--- /dev/null
+++ b/src/components/ScrollToTop.tsx
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+/**
+ * ScrollToTop component.
+ *
+ * Scrolls the window to the top whenever the route (pathname) changes.
+ * This component does not render any visible DOM elements.
+ */
+const ScrollToTop: React.FC = () => {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+
+ return null;
+};
+
+export default ScrollToTop;
diff --git a/src/data/mockAgents.ts b/src/data/mockAgents.ts
new file mode 100644
index 0000000..67d7925
--- /dev/null
+++ b/src/data/mockAgents.ts
@@ -0,0 +1,83 @@
+/**
+ * Mock agent data with Unsplash portrait URLs for development.
+ */
+
+import type { Agent } from '../types/models';
+
+/** Sample agents used throughout the application during development. */
+export const MOCK_AGENTS: Agent[] = [
+ {
+ id: 'agent-1',
+ name: 'James Mitchell',
+ title: 'Senior Real Estate Broker',
+ phone: '(555) 123-4567',
+ email: 'james.mitchell@luxeestates.com',
+ photo: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?w=400&h=400&fit=crop',
+ bio: 'James brings over 15 years of experience in luxury residential real estate. His deep knowledge of the local market and commitment to client satisfaction have earned him a reputation as one of the top agents in the region.',
+ specialties: ['Luxury Homes', 'Waterfront Properties', 'Investment Properties'],
+ propertiesCount: 42,
+ rating: 4.9,
+ socialLinks: {
+ linkedin: 'https://linkedin.com/in/jamesmitchell',
+ twitter: 'https://twitter.com/jamesmitchell',
+ },
+ },
+ {
+ id: 'agent-2',
+ name: 'Sarah Chen',
+ title: 'Residential Sales Specialist',
+ phone: '(555) 234-5678',
+ email: 'sarah.chen@luxeestates.com',
+ photo: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop',
+ bio: 'Sarah specializes in helping first-time buyers and families find their dream homes. Her patient approach and detailed market analysis make the buying process smooth and enjoyable.',
+ specialties: ['First-Time Buyers', 'Family Homes', 'Condominiums'],
+ propertiesCount: 35,
+ rating: 4.8,
+ socialLinks: {
+ linkedin: 'https://linkedin.com/in/sarahchen',
+ instagram: 'https://instagram.com/sarahchen_realty',
+ },
+ },
+ {
+ id: 'agent-3',
+ name: 'David Rodriguez',
+ title: 'Commercial & Residential Agent',
+ phone: '(555) 345-6789',
+ email: 'david.rodriguez@luxeestates.com',
+ photo: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop',
+ bio: 'David has a unique background in both commercial and residential real estate, giving his clients a comprehensive perspective on property investments and market trends.',
+ specialties: ['Commercial Properties', 'New Developments', 'Market Analysis'],
+ propertiesCount: 28,
+ rating: 4.7,
+ socialLinks: {
+ linkedin: 'https://linkedin.com/in/davidrodriguez',
+ facebook: 'https://facebook.com/davidrodriguezrealty',
+ },
+ },
+ {
+ id: 'agent-4',
+ name: 'Emily Parker',
+ title: 'Luxury Property Consultant',
+ phone: '(555) 456-7890',
+ email: 'emily.parker@luxeestates.com',
+ photo: 'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=400&h=400&fit=crop',
+ bio: 'Emily is passionate about connecting discerning buyers with exceptional properties. Her eye for design and understanding of luxury living ensure that every client finds a home that matches their lifestyle.',
+ specialties: ['Luxury Estates', 'Historic Homes', 'Relocation Services'],
+ propertiesCount: 31,
+ rating: 4.9,
+ socialLinks: {
+ linkedin: 'https://linkedin.com/in/emilyparker',
+ instagram: 'https://instagram.com/emilyparker_luxury',
+ },
+ },
+];
+
+/**
+ * Find an agent by their unique identifier.
+ *
+ * @param id - The agent's unique id string.
+ * @returns The matching Agent or undefined if not found.
+ */
+export function getAgentById(id: string): Agent | undefined {
+ return MOCK_AGENTS.find((agent) => agent.id === id);
+}
diff --git a/src/data/mockNeighborhoods.ts b/src/data/mockNeighborhoods.ts
new file mode 100644
index 0000000..5a1062e
--- /dev/null
+++ b/src/data/mockNeighborhoods.ts
@@ -0,0 +1,85 @@
+/**
+ * Mock neighborhood data with Unsplash cityscape/neighborhood URLs for development.
+ */
+
+import type { Neighborhood } from '../types/models';
+
+/** Sample neighborhoods used throughout the application during development. */
+export const MOCK_NEIGHBORHOODS: Neighborhood[] = [
+ {
+ id: 'neighborhood-1',
+ name: 'Maple Heights',
+ slug: 'maple-heights',
+ city: 'Austin',
+ state: 'TX',
+ description: 'A tree-lined neighborhood known for its charming bungalows, vibrant local shops, and excellent school district. Maple Heights offers a perfect blend of suburban tranquility and urban convenience.',
+ image: 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=800&h=600&fit=crop',
+ averagePrice: 585000,
+ walkScore: 82,
+ transitScore: 68,
+ highlights: ['Top-rated schools', 'Farmers market', 'Dog parks', 'Bike trails'],
+ featuredProperties: ['prop-1', 'prop-2'],
+ },
+ {
+ id: 'neighborhood-2',
+ name: 'Riverside District',
+ slug: 'riverside-district',
+ city: 'Austin',
+ state: 'TX',
+ description: 'Situated along the banks of the Colorado River, the Riverside District features stunning waterfront properties, upscale dining, and a thriving arts scene.',
+ image: 'https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?w=800&h=600&fit=crop',
+ averagePrice: 875000,
+ walkScore: 90,
+ transitScore: 78,
+ highlights: ['Waterfront views', 'Fine dining', 'Art galleries', 'Marina access'],
+ featuredProperties: ['prop-3', 'prop-4'],
+ },
+ {
+ id: 'neighborhood-3',
+ name: 'Summit Park',
+ slug: 'summit-park',
+ city: 'Denver',
+ state: 'CO',
+ description: 'An upscale community nestled in the foothills with panoramic mountain views, modern architecture, and miles of hiking trails right at your doorstep.',
+ image: 'https://images.unsplash.com/photo-1444723121867-7a241cacace9?w=800&h=600&fit=crop',
+ averagePrice: 1250000,
+ walkScore: 55,
+ transitScore: 42,
+ highlights: ['Mountain views', 'Hiking trails', 'Gated community', 'Golf course'],
+ featuredProperties: ['prop-5'],
+ },
+ {
+ id: 'neighborhood-4',
+ name: 'Harbor Point',
+ slug: 'harbor-point',
+ city: 'Seattle',
+ state: 'WA',
+ description: 'A vibrant waterfront neighborhood offering stunning Puget Sound views, eclectic restaurants, and a strong sense of community with year-round events.',
+ image: 'https://images.unsplash.com/photo-1514565131-fce0801e5785?w=800&h=600&fit=crop',
+ averagePrice: 950000,
+ walkScore: 88,
+ transitScore: 85,
+ highlights: ['Sound views', 'Ferry access', 'Seafood restaurants', 'Kayaking'],
+ featuredProperties: ['prop-6'],
+ },
+];
+
+/**
+ * Find a neighborhood by its unique identifier.
+ *
+ * @param id - The neighborhood's unique id string.
+ * @returns The matching Neighborhood or undefined if not found.
+ */
+export function getNeighborhoodById(id: string): Neighborhood | undefined {
+ return MOCK_NEIGHBORHOODS.find((n) => n.id === id);
+}
+
+/**
+ * Find a neighborhood by its URL-friendly slug.
+ *
+ * @param slug - The neighborhood's slug string.
+ * @returns The matching Neighborhood or undefined if not found.
+ */
+export function getNeighborhoodBySlug(slug: string): Neighborhood | undefined {
+ return MOCK_NEIGHBORHOODS.find((n) => n.slug === slug);
+}
diff --git a/src/data/mockProperties.ts b/src/data/mockProperties.ts
new file mode 100644
index 0000000..38aa80b
--- /dev/null
+++ b/src/data/mockProperties.ts
@@ -0,0 +1,250 @@
+/**
+ * Mock property data with Unsplash image URLs for development.
+ */
+
+import type { Property, PropertyStatus } from '../types/models';
+import { MOCK_AGENTS } from './mockAgents';
+import { MOCK_NEIGHBORHOODS } from './mockNeighborhoods';
+
+/** Sample properties used throughout the application during development. */
+export const MOCK_PROPERTIES: Property[] = [
+ {
+ id: 'prop-1',
+ title: 'Modern Farmhouse with Open Floor Plan',
+ slug: 'modern-farmhouse-open-floor-plan',
+ price: 625000,
+ address: '1234 Maple Grove Lane',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78701',
+ propertyType: 'house',
+ bedrooms: 4,
+ bathrooms: 3,
+ squareFeet: 2850,
+ lotSize: 0.35,
+ yearBuilt: 2021,
+ description:
+ 'This stunning modern farmhouse features an open-concept living area with soaring ceilings, a gourmet kitchen with quartz countertops, and a spacious primary suite. The backyard oasis includes a covered patio and mature landscaping.',
+ features: [
+ 'Open floor plan',
+ 'Quartz countertops',
+ 'Hardwood floors',
+ 'Smart home system',
+ 'Covered patio',
+ 'Two-car garage',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[0],
+ neighborhood: MOCK_NEIGHBORHOODS[0],
+ listingDate: '2024-01-15',
+ status: 'for-sale',
+ },
+ {
+ id: 'prop-2',
+ title: 'Charming Craftsman Bungalow',
+ slug: 'charming-craftsman-bungalow',
+ price: 475000,
+ address: '567 Oak Street',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78702',
+ propertyType: 'house',
+ bedrooms: 3,
+ bathrooms: 2,
+ squareFeet: 1950,
+ lotSize: 0.25,
+ yearBuilt: 1948,
+ description:
+ 'A beautifully restored Craftsman bungalow with original hardwood floors, built-in bookshelves, and a charming front porch. Updated kitchen and bathrooms blend modern convenience with period character.',
+ features: [
+ 'Original hardwood floors',
+ 'Built-in bookshelves',
+ 'Updated kitchen',
+ 'Front porch',
+ 'Fenced backyard',
+ 'Detached garage',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[1],
+ neighborhood: MOCK_NEIGHBORHOODS[0],
+ listingDate: '2024-02-01',
+ status: 'for-sale',
+ },
+ {
+ id: 'prop-3',
+ title: 'Luxury Waterfront Penthouse',
+ slug: 'luxury-waterfront-penthouse',
+ price: 1250000,
+ address: '200 Riverside Drive, PH-1',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78703',
+ propertyType: 'condo',
+ bedrooms: 3,
+ bathrooms: 3.5,
+ squareFeet: 3200,
+ lotSize: 0,
+ yearBuilt: 2023,
+ description:
+ 'An exceptional penthouse offering panoramic river views from floor-to-ceiling windows. Features include a private elevator, chef\'s kitchen with top-of-the-line appliances, and a wraparound terrace perfect for entertaining.',
+ features: [
+ 'Panoramic river views',
+ 'Private elevator',
+ 'Chef\'s kitchen',
+ 'Wraparound terrace',
+ 'Wine cellar',
+ 'Concierge service',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[3],
+ neighborhood: MOCK_NEIGHBORHOODS[1],
+ listingDate: '2024-01-20',
+ status: 'for-sale',
+ },
+ {
+ id: 'prop-4',
+ title: 'Contemporary Riverside Townhome',
+ slug: 'contemporary-riverside-townhome',
+ price: 725000,
+ address: '88 River Walk Court',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78703',
+ propertyType: 'townhouse',
+ bedrooms: 3,
+ bathrooms: 2.5,
+ squareFeet: 2400,
+ lotSize: 0.1,
+ yearBuilt: 2022,
+ description:
+ 'A sleek contemporary townhome with an open layout, rooftop deck, and direct access to the river walk trail. High-end finishes throughout, including waterfall island, spa-like primary bath, and custom closets.',
+ features: [
+ 'Rooftop deck',
+ 'River walk access',
+ 'Waterfall island',
+ 'Spa-like primary bath',
+ 'Custom closets',
+ 'EV charging station',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[2],
+ neighborhood: MOCK_NEIGHBORHOODS[1],
+ listingDate: '2024-02-10',
+ status: 'pending',
+ },
+ {
+ id: 'prop-5',
+ title: 'Mountain View Estate',
+ slug: 'mountain-view-estate',
+ price: 1850000,
+ address: '9500 Summit Ridge Road',
+ city: 'Denver',
+ state: 'CO',
+ zipCode: '80202',
+ propertyType: 'house',
+ bedrooms: 5,
+ bathrooms: 4.5,
+ squareFeet: 5200,
+ lotSize: 1.2,
+ yearBuilt: 2020,
+ description:
+ 'A breathtaking mountain estate with unobstructed views of the Rockies. This architectural masterpiece features walls of glass, a resort-style pool, home theater, and a six-car garage.',
+ features: [
+ 'Mountain views',
+ 'Resort-style pool',
+ 'Home theater',
+ 'Wine cellar',
+ 'Six-car garage',
+ 'Home gym',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[0],
+ neighborhood: MOCK_NEIGHBORHOODS[2],
+ listingDate: '2024-01-05',
+ status: 'for-sale',
+ },
+ {
+ id: 'prop-6',
+ title: 'Harbor View Apartment',
+ slug: 'harbor-view-apartment',
+ price: 550000,
+ address: '350 Harbor Boulevard, Unit 12C',
+ city: 'Seattle',
+ state: 'WA',
+ zipCode: '98101',
+ propertyType: 'apartment',
+ bedrooms: 2,
+ bathrooms: 2,
+ squareFeet: 1400,
+ lotSize: 0,
+ yearBuilt: 2019,
+ description:
+ 'A bright and airy apartment with sweeping views of Puget Sound. Features include an open kitchen with breakfast bar, in-unit laundry, and access to building amenities including a gym, pool, and rooftop lounge.',
+ features: [
+ 'Sound views',
+ 'In-unit laundry',
+ 'Building gym',
+ 'Rooftop lounge',
+ 'Concierge',
+ 'Pet-friendly',
+ ],
+ images: [
+ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop',
+ 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop',
+ ],
+ agent: MOCK_AGENTS[1],
+ neighborhood: MOCK_NEIGHBORHOODS[3],
+ listingDate: '2024-02-20',
+ status: 'sold',
+ },
+];
+
+/**
+ * Find a property by its URL-friendly slug.
+ *
+ * @param slug - The property's slug string.
+ * @returns The matching Property or undefined if not found.
+ */
+export function getPropertyBySlug(slug: string): Property | undefined {
+ return MOCK_PROPERTIES.find((p) => p.slug === slug);
+}
+
+/**
+ * Filter properties by their listing status.
+ *
+ * @param status - The status to filter by.
+ * @returns An array of properties matching the given status.
+ */
+export function getPropertiesByStatus(status: PropertyStatus): Property[] {
+ return MOCK_PROPERTIES.filter((p) => p.status === status);
+}
+
+/**
+ * Return the first 3 properties as featured listings.
+ *
+ * @returns An array of up to 3 featured properties.
+ */
+export function getFeaturedProperties(): Property[] {
+ return MOCK_PROPERTIES.slice(0, 3);
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..b158073
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import "./index.css";
+
+/**
+ * Application entry point.
+ *
+ * Mounts the React root into the #root DOM element, wrapping the
+ * application in React.StrictMode and BrowserRouter for client-side routing.
+ */
+const rootElement = document.getElementById("root");
+
+if (!rootElement) {
+ throw new Error(
+ "Root element not found. Ensure index.html contains
.",
+ );
+}
+
+ReactDOM.createRoot(rootElement).render(
+
+
+
+
+ ,
+);
diff --git a/src/pages/ContactPage.tsx b/src/pages/ContactPage.tsx
new file mode 100644
index 0000000..0914a5a
--- /dev/null
+++ b/src/pages/ContactPage.tsx
@@ -0,0 +1,207 @@
+import React, { useState } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+import type { ContactFormData, PreferredContact } from "../types/models";
+
+/**
+ * Contact page component.
+ *
+ * Renders a contact form that collects user inquiry details.
+ * Optionally pre-fills a propertyId when linked from a property detail page.
+ */
+const ContactPage: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const propertyId = searchParams.get("propertyId") ?? undefined;
+
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ phone: "",
+ message: "",
+ propertyId,
+ preferredContact: "email",
+ });
+
+ const [submitted, setSubmitted] = useState(false);
+
+ /**
+ * Handle input field changes and update form state.
+ */
+ const handleChange = (
+ e: React.ChangeEvent<
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+ >,
+ ): void => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ /**
+ * Handle form submission.
+ */
+ const handleSubmit = (e: React.FormEvent): void => {
+ e.preventDefault();
+ // In a real app this would POST to an API
+ setSubmitted(true);
+ };
+
+ if (submitted) {
+ return (
+
+
+
+ Thank You!
+
+
+ Your message has been sent. We'll get back to you shortly.
+
+
+ Back to Home
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Navigation */}
+
+
+ ← Back to Listings
+
+
+
+
+
+ );
+};
+
+export default ContactPage;
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
new file mode 100644
index 0000000..27f0690
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,412 @@
+import React, { useState, useMemo, useCallback, useRef } from 'react';
+import { Link } from 'react-router-dom';
+import { MOCK_PROPERTIES } from '../data/mockProperties';
+import { MOCK_AGENTS } from '../data/mockAgents';
+import { MOCK_NEIGHBORHOODS } from '../data/mockNeighborhoods';
+import { Property, PropertyType } from '../types/models';
+
+/* ------------------------------------------------------------------ */
+/* Filter state type */
+/* ------------------------------------------------------------------ */
+interface Filters {
+ location: string;
+ propertyType: PropertyType | '';
+ minPrice: number;
+ maxPrice: number;
+ minBeds: number;
+ minBaths: number;
+}
+
+const DEFAULT_FILTERS: Filters = {
+ location: '',
+ propertyType: '',
+ minPrice: 0,
+ maxPrice: 10000000,
+ minBeds: 0,
+ minBaths: 0,
+};
+
+/* ------------------------------------------------------------------ */
+/* Smooth-scroll helper */
+/* ------------------------------------------------------------------ */
+const scrollTo = (ref: React.RefObject) => {
+ ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+};
+
+/* ------------------------------------------------------------------ */
+/* HomePage */
+/* ------------------------------------------------------------------ */
+const HomePage: React.FC = () => {
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+
+ /* section refs for smooth scrolling */
+ const featuredRef = useRef(null);
+ const neighborhoodRef = useRef(null);
+ const agentsRef = useRef(null);
+
+ /* ---- filter logic ------------------------------------------------ */
+ const filteredProperties: Property[] = useMemo(() => {
+ return MOCK_PROPERTIES.filter((p) => {
+ if (
+ filters.location &&
+ !p.city.toLowerCase().includes(filters.location.toLowerCase()) &&
+ !p.state.toLowerCase().includes(filters.location.toLowerCase()) &&
+ !p.address.toLowerCase().includes(filters.location.toLowerCase())
+ ) {
+ return false;
+ }
+ if (filters.propertyType && p.propertyType !== filters.propertyType) return false;
+ if (p.price < filters.minPrice) return false;
+ if (p.price > filters.maxPrice) return false;
+ if (p.bedrooms < filters.minBeds) return false;
+ if (p.bathrooms < filters.minBaths) return false;
+ return true;
+ });
+ }, [filters]);
+
+ const hasActiveFilters = useMemo(
+ () =>
+ filters.location !== '' ||
+ filters.propertyType !== '' ||
+ filters.minPrice !== 0 ||
+ filters.maxPrice !== 10000000 ||
+ filters.minBeds !== 0 ||
+ filters.minBaths !== 0,
+ [filters],
+ );
+
+ const updateFilter = useCallback(
+ (key: K, value: Filters[K]) => {
+ setFilters((prev) => ({ ...prev, [key]: value }));
+ },
+ [],
+ );
+
+ const resetFilters = useCallback(() => setFilters(DEFAULT_FILTERS), []);
+
+ /* ---- render ------------------------------------------------------ */
+ return (
+
+ {/* ===================== HEADER ===================== */}
+
+
+
+ {/* ===================== HERO SECTION ===================== */}
+
+
+
+
+ Find Your Dream Home
+
+
+ Browse luxury real estate listings, connect with top agents, and explore the best neighborhoods.
+
+
scrollTo(featuredRef)}
+ className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-8 rounded-lg transition-colors"
+ >
+ Explore Properties
+
+
+
+
+ {/* ===================== SEARCH / FILTER BAR ===================== */}
+
+
+
+ {/* Location */}
+
+ Location
+ updateFilter('location', e.target.value)}
+ placeholder="City, state, or address"
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+ {/* Property Type */}
+
+ Type
+ updateFilter('propertyType', e.target.value as PropertyType | '')}
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
+ >
+ All Types
+ House
+ Condo
+ Townhouse
+ Apartment
+ Land
+
+
+
+ {/* Price Range */}
+
+ Max Price
+ updateFilter('maxPrice', Number(e.target.value))}
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
+ >
+ Any
+ $300k
+ $500k
+ $750k
+ $1M
+ $2M
+
+
+
+ {/* Beds */}
+
+ Beds
+ updateFilter('minBeds', Number(e.target.value))}
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
+ >
+ Any
+ 1+
+ 2+
+ 3+
+ 4+
+ 5+
+
+
+
+ {/* Baths */}
+
+ Baths
+ updateFilter('minBaths', Number(e.target.value))}
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
+ >
+ Any
+ 1+
+ 2+
+ 3+
+ 4+
+
+
+
+
+ {hasActiveFilters && (
+
+
+ {filteredProperties.length} {filteredProperties.length === 1 ? 'property' : 'properties'} found
+
+
+ Reset Filters
+
+
+ )}
+
+
+
+ {/* ===================== FEATURED / FILTERED PROPERTIES ===================== */}
+
+
+ {hasActiveFilters ? 'Search Results' : 'Featured Properties'}
+
+
+ {hasActiveFilters
+ ? `Showing ${filteredProperties.length} matching properties`
+ : 'Hand-picked homes you\'ll love'}
+
+
+ {filteredProperties.length === 0 ? (
+
+
No properties match your filters.
+
+ Clear all filters
+
+
+ ) : (
+
+ {(hasActiveFilters ? filteredProperties : filteredProperties.slice(0, 6)).map((property) => (
+
+
+
+
+ {property.status.replace('-', ' ')}
+
+
+
+
+ {property.title}
+
+
+ {property.address}, {property.city}, {property.state}
+
+
+ ${property.price.toLocaleString()}
+
+
+ {property.bedrooms} bd
+ {property.bathrooms} ba
+ {property.squareFeet.toLocaleString()} sqft
+
+
+
+ ))}
+
+ )}
+
+
+ {/* ===================== NEIGHBORHOOD HIGHLIGHTS ===================== */}
+
+
+
Explore Neighborhoods
+
Discover the communities that make each area special
+
+ {MOCK_NEIGHBORHOODS.map((hood) => (
+
{
+ updateFilter('location', hood.city);
+ scrollTo(featuredRef);
+ }}
+ >
+
+
+
+
+
+
{hood.name}
+
{hood.city}, {hood.state}
+
+
+ Avg ${(hood.averagePrice / 1000).toFixed(0)}k
+
+
+ Walk {hood.walkScore}
+
+
+
+
+ ))}
+
+
+
+
+ {/* ===================== AGENTS SECTION ===================== */}
+
+ Meet Our Agents
+ Experienced professionals ready to help you
+
+ {MOCK_AGENTS.map((agent) => (
+
+
+
{agent.name}
+
{agent.title}
+
{agent.bio}
+
+
+ ★ {agent.rating}
+
+
+ {agent.propertiesCount} listings
+
+
+
+ ))}
+
+
+
+
+ {/* ===================== FOOTER ===================== */}
+
+
+ );
+};
+
+export default HomePage;
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..ce66eb9
--- /dev/null
+++ b/src/pages/NotFoundPage.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+/**
+ * NotFoundPage component.
+ *
+ * Displays a styled 404 error page when a user navigates to an
+ * unknown route. Provides navigation links back to the home page
+ * and the contact page.
+ */
+const NotFoundPage: React.FC = () => {
+ return (
+
+ {/* Large 404 indicator */}
+
+
404
+
+
+ {/* Messaging */}
+
+ Page Not Found
+
+
+ Sorry, the page you're looking for doesn't exist or has been
+ moved. Let's get you back on track.
+
+
+ {/* Navigation links */}
+
+
+
+
+
+ Back to Home
+
+
+
+
+
+ Contact Us
+
+
+
+ );
+};
+
+export default NotFoundPage;
diff --git a/src/pages/PropertyDetailPage.tsx b/src/pages/PropertyDetailPage.tsx
new file mode 100644
index 0000000..df8e167
--- /dev/null
+++ b/src/pages/PropertyDetailPage.tsx
@@ -0,0 +1,360 @@
+import React, { useState } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { MOCK_PROPERTIES } from '../data/mockProperties';
+import { ContactFormData, PreferredContact } from '../types/models';
+
+/**
+ * Property detail page component.
+ *
+ * Displays comprehensive information about a single property listing,
+ * including photo gallery, property details, description, features,
+ * agent card sidebar, and contact form.
+ * Responsive layout: main content + sidebar on desktop, stacked on mobile.
+ */
+const PropertyDetailPage: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const property = MOCK_PROPERTIES.find((p) => p.id === id);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ email: '',
+ phone: '',
+ message: property ? `I'm interested in "${property.title}" at ${property.address}. Please send me more information.` : '',
+ propertyId: id,
+ preferredContact: 'email',
+ });
+ const [formSubmitted, setFormSubmitted] = useState(false);
+ const [galleryIndex, setGalleryIndex] = useState(0);
+
+ if (!property) {
+ return (
+
+
+
+ Property Not Found
+ The property you are looking for does not exist.
+ ← Back to Listings
+
+
+
+ );
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setFormSubmitted(true);
+ };
+
+ const statusLabel: Record = { 'for-sale': 'For Sale', pending: 'Pending', sold: 'Sold' };
+ const statusColor: Record = {
+ 'for-sale': 'bg-green-100 text-green-800',
+ pending: 'bg-yellow-100 text-yellow-800',
+ sold: 'bg-red-100 text-red-800',
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Breadcrumb */}
+
+ ← Back to Listings
+
+
+ {/* Photo Gallery */}
+
+
+
+
+
+ {property.images.length > 1 && (
+ <>
+
setGalleryIndex((prev) => (prev === 0 ? property.images.length - 1 : prev - 1))}
+ className="absolute left-3 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white text-slate-800 rounded-full w-10 h-10 flex items-center justify-center shadow-md transition-colors"
+ aria-label="Previous image"
+ >
+ ‹
+
+
setGalleryIndex((prev) => (prev === property.images.length - 1 ? 0 : prev + 1))}
+ className="absolute right-3 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white text-slate-800 rounded-full w-10 h-10 flex items-center justify-center shadow-md transition-colors"
+ aria-label="Next image"
+ >
+ ›
+
+ >
+ )}
+
+ {galleryIndex + 1} / {property.images.length}
+
+
+ {/* Thumbnails */}
+
+ {property.images.map((img, i) => (
+
setGalleryIndex(i)}
+ className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-colors ${
+ i === galleryIndex ? 'border-blue-600' : 'border-transparent hover:border-slate-300'
+ }`}
+ >
+
+
+ ))}
+
+
+
+ {/* Content + Sidebar */}
+
+
+ {/* Main Content */}
+
+ {/* Status badge */}
+
+
+ {statusLabel[property.status] || property.status}
+
+
+
+ {/* Title & Address */}
+
{property.title}
+
+
+ {property.address}, {property.city}, {property.state} {property.zipCode}
+
+
${property.price.toLocaleString()}
+
+ {/* Stats with icons */}
+
+
+
+
{property.bedrooms}
+
Bedrooms
+
+
+
+
{property.bathrooms}
+
Bathrooms
+
+
+
+
{property.squareFeet.toLocaleString()}
+
Sq Ft
+
+
+
+
{property.yearBuilt}
+
Year Built
+
+
+
+ {/* Description */}
+
+
About This Property
+
{property.description}
+
+
+ {/* Property Details */}
+
+
Property Details
+
+
+ Property Type
+ {property.propertyType}
+
+
+ Status
+ {statusLabel[property.status]}
+
+
+ Bedrooms
+ {property.bedrooms}
+
+
+ Bathrooms
+ {property.bathrooms}
+
+
+ Square Feet
+ {property.squareFeet.toLocaleString()}
+
+ {property.lotSize && (
+
+ Lot Size
+ {property.lotSize}
+
+ )}
+
+ Year Built
+ {property.yearBuilt}
+
+
+ Listed
+ {new Date(property.listingDate).toLocaleDateString()}
+
+
+
+
+ {/* Features */}
+
+
Features & Amenities
+
+ {property.features.map((feature, i) => (
+
+
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Agent Card */}
+
+
Listing Agent
+
+
+
+
{property.agent.name}
+
{property.agent.title}
+
+
+
{property.agent.rating}
+
• {property.agent.propertiesCount} listings
+
+
+
+
{property.agent.bio}
+
+
+ {property.agent.specialties.map((spec, i) => (
+ {spec}
+ ))}
+
+
+
+ {/* Contact Form */}
+
+
Schedule a Tour
+ {formSubmitted ? (
+
+
+
Message Sent!
+
We'll get back to you shortly.
+
setFormSubmitted(false)} className="mt-4 text-blue-600 hover:text-blue-700 text-sm font-medium">Send another message
+
+ ) : (
+
+
+ Full Name
+
+
+
+ Email
+
+
+
+ Phone
+
+
+
+ Preferred Contact
+
+ Email
+ Phone
+ Either
+
+
+
+ Message
+
+
+
+ Send Message
+
+
+ )}
+
+
+
+
+
+
+ {/* Footer */}
+
+
+ );
+};
+
+export default PropertyDetailPage;
diff --git a/src/types/models.ts b/src/types/models.ts
new file mode 100644
index 0000000..cf7fa58
--- /dev/null
+++ b/src/types/models.ts
@@ -0,0 +1,86 @@
+/**
+ * TypeScript interfaces for all domain data models used across the app.
+ */
+
+/** The type of a property listing. */
+export type PropertyType = 'house' | 'condo' | 'townhouse' | 'apartment' | 'land';
+
+/** The current status of a property listing. */
+export type PropertyStatus = 'for-sale' | 'pending' | 'sold';
+
+/** The preferred contact method for a form submission. */
+export type PreferredContact = 'email' | 'phone' | 'either';
+
+/** Social media links for an agent. */
+export interface SocialLinks {
+ linkedin?: string;
+ twitter?: string;
+ facebook?: string;
+ instagram?: string;
+}
+
+/** Represents a real estate agent. */
+export interface Agent {
+ id: string;
+ name: string;
+ title: string;
+ phone: string;
+ email: string;
+ photo: string;
+ bio: string;
+ specialties: string[];
+ propertiesCount: number;
+ rating: number;
+ socialLinks: SocialLinks;
+}
+
+/** Represents a neighborhood or area. */
+export interface Neighborhood {
+ id: string;
+ name: string;
+ slug: string;
+ city: string;
+ state: string;
+ description: string;
+ image: string;
+ averagePrice: number;
+ walkScore: number;
+ transitScore: number;
+ highlights: string[];
+ featuredProperties: string[];
+}
+
+/** Represents a property listing. */
+export interface Property {
+ id: string;
+ title: string;
+ slug: string;
+ price: number;
+ address: string;
+ city: string;
+ state: string;
+ zipCode: string;
+ propertyType: PropertyType;
+ bedrooms: number;
+ bathrooms: number;
+ squareFeet: number;
+ lotSize: number;
+ yearBuilt: number;
+ description: string;
+ features: string[];
+ images: string[];
+ agent: Agent;
+ neighborhood: Neighborhood;
+ listingDate: string;
+ status: PropertyStatus;
+}
+
+/** Data submitted via the contact form. */
+export interface ContactFormData {
+ name: string;
+ email: string;
+ phone: string;
+ message: string;
+ propertyId?: string;
+ preferredContact: PreferredContact;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tests/App.test.tsx b/tests/App.test.tsx
new file mode 100644
index 0000000..e3cf8d4
--- /dev/null
+++ b/tests/App.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Tests for the App component and route definitions.
+ *
+ * Uses MemoryRouter to simulate navigation and verify that the
+ * correct page components render for each route.
+ */
+
+import React from "react";
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import App from "../src/App";
+
+/**
+ * Helper to render App at a specific route path.
+ */
+function renderAtPath(path: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("App routing", () => {
+ it('should render the HomePage at "/"', () => {
+ renderAtPath("/");
+ expect(
+ screen.getByText("Find Your Dream Home"),
+ ).toBeDefined();
+ });
+
+ it('should render the HomePage with "Featured Properties" section', () => {
+ renderAtPath("/");
+ expect(
+ screen.getByText("Featured Properties"),
+ ).toBeDefined();
+ });
+
+ it('should render PropertyDetailPage at "/property/:id"', () => {
+ renderAtPath("/property/prop-1");
+ expect(
+ screen.getByText("Modern Downtown Loft"),
+ ).toBeDefined();
+ });
+
+ it("should render Property Not Found for an invalid property ID", () => {
+ renderAtPath("/property/non-existent");
+ expect(
+ screen.getByText("Property Not Found"),
+ ).toBeDefined();
+ });
+
+ it('should render the ContactPage at "/contact"', () => {
+ renderAtPath("/contact");
+ expect(screen.getByText("Contact Us")).toBeDefined();
+ });
+
+ it("should render the contact form fields", () => {
+ renderAtPath("/contact");
+ expect(screen.getByLabelText("Full Name")).toBeDefined();
+ expect(screen.getByLabelText("Email Address")).toBeDefined();
+ expect(screen.getByLabelText("Message")).toBeDefined();
+ });
+});
diff --git a/tests/data/mockAgents.test.ts b/tests/data/mockAgents.test.ts
new file mode 100644
index 0000000..93e33da
--- /dev/null
+++ b/tests/data/mockAgents.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Tests for mock agent data and helper functions.
+ *
+ * Validates data integrity, photo URL formats, and lookup behaviour.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { MOCK_AGENTS, getAgentById } from '../../src/data/mockAgents';
+
+describe('MOCK_AGENTS', () => {
+ it('should contain at least 3 agents', () => {
+ expect(MOCK_AGENTS.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('should have unique IDs for every agent', () => {
+ const ids = MOCK_AGENTS.map((a) => a.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(ids.length);
+ });
+
+ it('should use Unsplash URLs for all agent photos', () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.photo).toMatch(/^https:\/\/images\.unsplash\.com\/photo-/);
+ }
+ });
+
+ it('should have non-empty name, title, phone, email, and bio', () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.name.length).toBeGreaterThan(0);
+ expect(agent.title.length).toBeGreaterThan(0);
+ expect(agent.phone.length).toBeGreaterThan(0);
+ expect(agent.email.length).toBeGreaterThan(0);
+ expect(agent.bio.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should have valid email format', () => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.email).toMatch(emailRegex);
+ }
+ });
+
+ it('should have at least one specialty per agent', () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.specialties.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it('should have ratings between 1 and 5', () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.rating).toBeGreaterThanOrEqual(1);
+ expect(agent.rating).toBeLessThanOrEqual(5);
+ }
+ });
+
+ it('should have non-negative propertiesCount', () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.propertiesCount).toBeGreaterThanOrEqual(0);
+ }
+ });
+});
+
+describe('getAgentById', () => {
+ it('should return the correct agent for a known ID', () => {
+ const agent = getAgentById('agent-001');
+ expect(agent).toBeDefined();
+ expect(agent!.name).toBe('David Mitchell');
+ });
+
+ it('should return the correct agent for agent-002', () => {
+ const agent = getAgentById('agent-002');
+ expect(agent).toBeDefined();
+ expect(agent!.name).toBe('Sarah Chen');
+ });
+
+ it('should return the correct agent for agent-003', () => {
+ const agent = getAgentById('agent-003');
+ expect(agent).toBeDefined();
+ expect(agent!.name).toBe('Marcus Rivera');
+ });
+
+ it('should return undefined for an unknown ID', () => {
+ const agent = getAgentById('agent-999');
+ expect(agent).toBeUndefined();
+ });
+});
diff --git a/tests/data/mockNeighborhoods.test.ts b/tests/data/mockNeighborhoods.test.ts
new file mode 100644
index 0000000..b989827
--- /dev/null
+++ b/tests/data/mockNeighborhoods.test.ts
@@ -0,0 +1,110 @@
+/**
+ * Tests for mock neighborhood data and helper functions.
+ *
+ * Validates data integrity, image URL formats, and lookup behaviour.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ MOCK_NEIGHBORHOODS,
+ getNeighborhoodBySlug,
+ getNeighborhoodById,
+} from '../../src/data/mockNeighborhoods';
+
+describe('MOCK_NEIGHBORHOODS', () => {
+ it('should contain exactly 6 neighborhoods', () => {
+ expect(MOCK_NEIGHBORHOODS).toHaveLength(6);
+ });
+
+ it('should have unique IDs for every neighborhood', () => {
+ const ids = MOCK_NEIGHBORHOODS.map((n) => n.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(ids.length);
+ });
+
+ it('should have unique slugs for every neighborhood', () => {
+ const slugs = MOCK_NEIGHBORHOODS.map((n) => n.slug);
+ const uniqueSlugs = new Set(slugs);
+ expect(uniqueSlugs.size).toBe(slugs.length);
+ });
+
+ it('should use Unsplash URLs for all neighborhood images', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.image).toMatch(/^https:\/\/images\.unsplash\.com\/photo-/);
+ }
+ });
+
+ it('should have non-empty name, description, city, and state', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.name.length).toBeGreaterThan(0);
+ expect(neighborhood.description.length).toBeGreaterThan(0);
+ expect(neighborhood.city.length).toBeGreaterThan(0);
+ expect(neighborhood.state.length).toBe(2);
+ }
+ });
+
+ it('should have positive averagePrice', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.averagePrice).toBeGreaterThan(0);
+ }
+ });
+
+ it('should have walkScore between 0 and 100', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.walkScore).toBeGreaterThanOrEqual(0);
+ expect(neighborhood.walkScore).toBeLessThanOrEqual(100);
+ }
+ });
+
+ it('should have transitScore between 0 and 100', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.transitScore).toBeGreaterThanOrEqual(0);
+ expect(neighborhood.transitScore).toBeLessThanOrEqual(100);
+ }
+ });
+
+ it('should have at least one highlight per neighborhood', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.highlights.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it('should have at least one featured property ID per neighborhood', () => {
+ for (const neighborhood of MOCK_NEIGHBORHOODS) {
+ expect(neighborhood.featuredPropertyIds.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+});
+
+describe('getNeighborhoodBySlug', () => {
+ it('should return the correct neighborhood for a known slug', () => {
+ const neighborhood = getNeighborhoodBySlug('downtown-austin');
+ expect(neighborhood).toBeDefined();
+ expect(neighborhood!.id).toBe('hood-002');
+ expect(neighborhood!.name).toBe('Downtown Austin');
+ });
+
+ it('should return the correct neighborhood for south-congress', () => {
+ const neighborhood = getNeighborhoodBySlug('south-congress');
+ expect(neighborhood).toBeDefined();
+ expect(neighborhood!.id).toBe('hood-003');
+ });
+
+ it('should return undefined for an unknown slug', () => {
+ const neighborhood = getNeighborhoodBySlug('nonexistent-area');
+ expect(neighborhood).toBeUndefined();
+ });
+});
+
+describe('getNeighborhoodById', () => {
+ it('should return the correct neighborhood for a known ID', () => {
+ const neighborhood = getNeighborhoodById('hood-001');
+ expect(neighborhood).toBeDefined();
+ expect(neighborhood!.name).toBe('Lake Austin');
+ });
+
+ it('should return undefined for an unknown ID', () => {
+ const neighborhood = getNeighborhoodById('hood-999');
+ expect(neighborhood).toBeUndefined();
+ });
+});
diff --git a/tests/data/mockProperties.test.ts b/tests/data/mockProperties.test.ts
new file mode 100644
index 0000000..5eda846
--- /dev/null
+++ b/tests/data/mockProperties.test.ts
@@ -0,0 +1,193 @@
+/**
+ * Tests for mock property data and helper functions.
+ *
+ * Validates data integrity, image URL formats, and lookup/filter behaviour.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ MOCK_PROPERTIES,
+ getPropertyBySlug,
+ getPropertiesByStatus,
+ getFeaturedProperties,
+ getPropertyById,
+ getPropertiesByNeighborhood,
+ getPropertiesByAgent,
+} from '../../src/data/mockProperties';
+
+describe('MOCK_PROPERTIES', () => {
+ it('should contain at least 12 properties', () => {
+ expect(MOCK_PROPERTIES.length).toBeGreaterThanOrEqual(12);
+ });
+
+ it('should have unique IDs for every property', () => {
+ const ids = MOCK_PROPERTIES.map((p) => p.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(ids.length);
+ });
+
+ it('should have unique slugs for every property', () => {
+ const slugs = MOCK_PROPERTIES.map((p) => p.slug);
+ const uniqueSlugs = new Set(slugs);
+ expect(uniqueSlugs.size).toBe(slugs.length);
+ });
+
+ it('should have at least one image per property', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.images.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it('should use Unsplash URLs for all images', () => {
+ for (const property of MOCK_PROPERTIES) {
+ for (const img of property.images) {
+ expect(img).toMatch(/^https:\/\/images\.unsplash\.com\/photo-/);
+ }
+ }
+ });
+
+ it('should have valid price values (positive numbers)', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.price).toBeGreaterThan(0);
+ }
+ });
+
+ it('should have valid bedroom counts (non-negative)', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.bedrooms).toBeGreaterThanOrEqual(0);
+ }
+ });
+
+ it('should have valid bathroom counts (positive)', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.bathrooms).toBeGreaterThan(0);
+ }
+ });
+
+ it('should have valid squareFeet (positive)', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.squareFeet).toBeGreaterThan(0);
+ }
+ });
+
+ it('should have valid yearBuilt (>= 1800)', () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.yearBuilt).toBeGreaterThanOrEqual(1800);
+ }
+ });
+
+ it('should have valid status values', () => {
+ const validStatuses = new Set(['for-sale', 'pending', 'sold']);
+ for (const property of MOCK_PROPERTIES) {
+ expect(validStatuses.has(property.status)).toBe(true);
+ }
+ });
+
+ it('should have valid propertyType values', () => {
+ const validTypes = new Set(['house', 'condo', 'townhouse', 'apartment', 'land']);
+ for (const property of MOCK_PROPERTIES) {
+ expect(validTypes.has(property.propertyType)).toBe(true);
+ }
+ });
+});
+
+describe('getPropertyBySlug', () => {
+ it('should return the correct property for a known slug', () => {
+ const property = getPropertyBySlug('modern-lakefront-estate');
+ expect(property).toBeDefined();
+ expect(property!.id).toBe('prop-001');
+ });
+
+ it('should return undefined for an unknown slug', () => {
+ const property = getPropertyBySlug('nonexistent-slug');
+ expect(property).toBeUndefined();
+ });
+});
+
+describe('getPropertiesByStatus', () => {
+ it('should return only for-sale properties', () => {
+ const forSale = getPropertiesByStatus('for-sale');
+ expect(forSale.length).toBeGreaterThan(0);
+ for (const p of forSale) {
+ expect(p.status).toBe('for-sale');
+ }
+ });
+
+ it('should return only pending properties', () => {
+ const pending = getPropertiesByStatus('pending');
+ expect(pending.length).toBeGreaterThan(0);
+ for (const p of pending) {
+ expect(p.status).toBe('pending');
+ }
+ });
+
+ it('should return only sold properties', () => {
+ const sold = getPropertiesByStatus('sold');
+ expect(sold.length).toBeGreaterThan(0);
+ for (const p of sold) {
+ expect(p.status).toBe('sold');
+ }
+ });
+});
+
+describe('getFeaturedProperties', () => {
+ it('should return at most 3 properties', () => {
+ const featured = getFeaturedProperties();
+ expect(featured.length).toBeLessThanOrEqual(3);
+ });
+
+ it('should return only featured properties', () => {
+ const featured = getFeaturedProperties();
+ for (const p of featured) {
+ expect(p.featured).toBe(true);
+ }
+ });
+
+ it('should return at least 1 featured property', () => {
+ const featured = getFeaturedProperties();
+ expect(featured.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+describe('getPropertyById', () => {
+ it('should return the correct property for a known ID', () => {
+ const property = getPropertyById('prop-003');
+ expect(property).toBeDefined();
+ expect(property!.slug).toBe('charming-craftsman-bungalow');
+ });
+
+ it('should return undefined for an unknown ID', () => {
+ const property = getPropertyById('prop-999');
+ expect(property).toBeUndefined();
+ });
+});
+
+describe('getPropertiesByNeighborhood', () => {
+ it('should return properties in the given neighborhood', () => {
+ const properties = getPropertiesByNeighborhood('hood-003');
+ expect(properties.length).toBeGreaterThan(0);
+ for (const p of properties) {
+ expect(p.neighborhoodId).toBe('hood-003');
+ }
+ });
+
+ it('should return empty array for unknown neighborhood', () => {
+ const properties = getPropertiesByNeighborhood('hood-999');
+ expect(properties).toEqual([]);
+ });
+});
+
+describe('getPropertiesByAgent', () => {
+ it('should return properties listed by the given agent', () => {
+ const properties = getPropertiesByAgent('agent-001');
+ expect(properties.length).toBeGreaterThan(0);
+ for (const p of properties) {
+ expect(p.agentId).toBe('agent-001');
+ }
+ });
+
+ it('should return empty array for unknown agent', () => {
+ const properties = getPropertiesByAgent('agent-999');
+ expect(properties).toEqual([]);
+ });
+});
diff --git a/tests/mockData.test.ts b/tests/mockData.test.ts
new file mode 100644
index 0000000..c6b7321
--- /dev/null
+++ b/tests/mockData.test.ts
@@ -0,0 +1,169 @@
+/**
+ * Tests for mock data modules and their helper functions.
+ *
+ * Verifies that mock data is correctly structured and that lookup /
+ * filter helpers return expected results.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ MOCK_PROPERTIES,
+ getPropertyBySlug,
+ getPropertiesByStatus,
+ getFeaturedProperties,
+} from "../src/data/mockProperties";
+import { MOCK_AGENTS, getAgentById } from "../src/data/mockAgents";
+import {
+ MOCK_NEIGHBORHOODS,
+ getNeighborhoodBySlug,
+} from "../src/data/mockNeighborhoods";
+
+describe("MOCK_PROPERTIES", () => {
+ it("should contain 6 properties", () => {
+ expect(MOCK_PROPERTIES).toHaveLength(6);
+ });
+
+ it("should have unique IDs", () => {
+ const ids = MOCK_PROPERTIES.map((p) => p.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("should have unique slugs", () => {
+ const slugs = MOCK_PROPERTIES.map((p) => p.slug);
+ expect(new Set(slugs).size).toBe(slugs.length);
+ });
+
+ it("every property should have at least one image", () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.images.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it("every property should reference a valid agent", () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.agent).toBeDefined();
+ expect(property.agent.id).toBeTruthy();
+ }
+ });
+
+ it("every property should reference a valid neighborhood", () => {
+ for (const property of MOCK_PROPERTIES) {
+ expect(property.neighborhood).toBeDefined();
+ expect(property.neighborhood.id).toBeTruthy();
+ }
+ });
+});
+
+describe("getPropertyBySlug", () => {
+ it("should find a property by its slug", () => {
+ const result = getPropertyBySlug("modern-downtown-loft");
+ expect(result).toBeDefined();
+ expect(result?.id).toBe("prop-1");
+ });
+
+ it("should return undefined for a non-existent slug", () => {
+ const result = getPropertyBySlug("non-existent-slug");
+ expect(result).toBeUndefined();
+ });
+});
+
+describe("getPropertiesByStatus", () => {
+ it("should return properties with for-sale status", () => {
+ const forSale = getPropertiesByStatus("for-sale");
+ expect(forSale.length).toBeGreaterThan(0);
+ for (const p of forSale) {
+ expect(p.status).toBe("for-sale");
+ }
+ });
+
+ it("should return properties with pending status", () => {
+ const pending = getPropertiesByStatus("pending");
+ expect(pending.length).toBeGreaterThanOrEqual(1);
+ for (const p of pending) {
+ expect(p.status).toBe("pending");
+ }
+ });
+
+ it("should return empty array for sold status (none in mock data)", () => {
+ const sold = getPropertiesByStatus("sold");
+ expect(sold).toHaveLength(0);
+ });
+});
+
+describe("getFeaturedProperties", () => {
+ it("should return exactly 3 properties", () => {
+ const featured = getFeaturedProperties();
+ expect(featured).toHaveLength(3);
+ });
+
+ it("should return the first 3 properties from the list", () => {
+ const featured = getFeaturedProperties();
+ expect(featured[0].id).toBe(MOCK_PROPERTIES[0].id);
+ expect(featured[1].id).toBe(MOCK_PROPERTIES[1].id);
+ expect(featured[2].id).toBe(MOCK_PROPERTIES[2].id);
+ });
+});
+
+describe("MOCK_AGENTS", () => {
+ it("should contain 4 agents", () => {
+ expect(MOCK_AGENTS).toHaveLength(4);
+ });
+
+ it("should have unique IDs", () => {
+ const ids = MOCK_AGENTS.map((a) => a.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("every agent should have a rating between 0 and 5", () => {
+ for (const agent of MOCK_AGENTS) {
+ expect(agent.rating).toBeGreaterThanOrEqual(0);
+ expect(agent.rating).toBeLessThanOrEqual(5);
+ }
+ });
+});
+
+describe("getAgentById", () => {
+ it("should find an agent by ID", () => {
+ const result = getAgentById("agent-1");
+ expect(result).toBeDefined();
+ expect(result?.name).toBe("James Mitchell");
+ });
+
+ it("should return undefined for a non-existent ID", () => {
+ const result = getAgentById("agent-999");
+ expect(result).toBeUndefined();
+ });
+});
+
+describe("MOCK_NEIGHBORHOODS", () => {
+ it("should contain 4 neighborhoods", () => {
+ expect(MOCK_NEIGHBORHOODS).toHaveLength(4);
+ });
+
+ it("should have unique slugs", () => {
+ const slugs = MOCK_NEIGHBORHOODS.map((n) => n.slug);
+ expect(new Set(slugs).size).toBe(slugs.length);
+ });
+
+ it("every neighborhood should have walk and transit scores in 0-100", () => {
+ for (const n of MOCK_NEIGHBORHOODS) {
+ expect(n.walkScore).toBeGreaterThanOrEqual(0);
+ expect(n.walkScore).toBeLessThanOrEqual(100);
+ expect(n.transitScore).toBeGreaterThanOrEqual(0);
+ expect(n.transitScore).toBeLessThanOrEqual(100);
+ }
+ });
+});
+
+describe("getNeighborhoodBySlug", () => {
+ it("should find a neighborhood by slug", () => {
+ const result = getNeighborhoodBySlug("downtown-heights");
+ expect(result).toBeDefined();
+ expect(result?.id).toBe("neighborhood-1");
+ });
+
+ it("should return undefined for a non-existent slug", () => {
+ const result = getNeighborhoodBySlug("no-such-place");
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/tests/models.test.ts b/tests/models.test.ts
new file mode 100644
index 0000000..307dc2f
--- /dev/null
+++ b/tests/models.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Tests for TypeScript data models.
+ *
+ * These tests verify that the type definitions are correctly exported
+ * and that mock data conforms to the expected interfaces.
+ */
+
+import { describe, it, expect } from "vitest";
+import type {
+ Property,
+ Agent,
+ Neighborhood,
+ ContactFormData,
+ PropertyType,
+ PropertyStatus,
+ PreferredContact,
+} from "../src/types/models";
+
+describe("Type models", () => {
+ it("should create a valid Agent object", () => {
+ const agent: Agent = {
+ id: "test-agent",
+ name: "Test Agent",
+ title: "Senior Agent",
+ phone: "(555) 000-0000",
+ email: "test@example.com",
+ photo: "https://example.com/photo.jpg",
+ bio: "A test agent bio.",
+ specialties: ["Testing"],
+ propertiesCount: 5,
+ rating: 4.5,
+ socialLinks: { linkedin: "https://linkedin.com/in/test" },
+ };
+
+ expect(agent.id).toBe("test-agent");
+ expect(agent.rating).toBeGreaterThanOrEqual(0);
+ expect(agent.rating).toBeLessThanOrEqual(5);
+ });
+
+ it("should create a valid Neighborhood object", () => {
+ const neighborhood: Neighborhood = {
+ id: "test-neighborhood",
+ name: "Testville",
+ slug: "testville",
+ city: "Test City",
+ state: "TS",
+ description: "A test neighborhood.",
+ image: "https://example.com/neighborhood.jpg",
+ averagePrice: 500000,
+ walkScore: 80,
+ transitScore: 60,
+ highlights: ["Great for testing"],
+ featuredProperties: ["prop-1"],
+ };
+
+ expect(neighborhood.slug).toBe("testville");
+ expect(neighborhood.walkScore).toBeLessThanOrEqual(100);
+ });
+
+ it("should create a valid ContactFormData object", () => {
+ const formData: ContactFormData = {
+ name: "John Doe",
+ email: "john@example.com",
+ phone: "(555) 123-4567",
+ message: "I am interested.",
+ propertyId: "prop-1",
+ preferredContact: "email",
+ };
+
+ expect(formData.name).toBe("John Doe");
+ expect(formData.propertyId).toBe("prop-1");
+ });
+
+ it("should allow ContactFormData without optional propertyId", () => {
+ const formData: ContactFormData = {
+ name: "Jane Doe",
+ email: "jane@example.com",
+ phone: "",
+ message: "General inquiry.",
+ preferredContact: "either",
+ };
+
+ expect(formData.propertyId).toBeUndefined();
+ });
+
+ it("should accept all valid PropertyType values", () => {
+ const types: PropertyType[] = [
+ "house",
+ "condo",
+ "townhouse",
+ "apartment",
+ "land",
+ ];
+ expect(types).toHaveLength(5);
+ });
+
+ it("should accept all valid PropertyStatus values", () => {
+ const statuses: PropertyStatus[] = ["for-sale", "pending", "sold"];
+ expect(statuses).toHaveLength(3);
+ });
+
+ it("should accept all valid PreferredContact values", () => {
+ const contacts: PreferredContact[] = ["email", "phone", "either"];
+ expect(contacts).toHaveLength(3);
+ });
+});
diff --git a/tests/test_app_routes.tsx b/tests/test_app_routes.tsx
new file mode 100644
index 0000000..d51fc46
--- /dev/null
+++ b/tests/test_app_routes.tsx
@@ -0,0 +1,72 @@
+/**
+ * Tests for App component routing configuration.
+ *
+ * Run with: npx vitest run tests/test_app_routes.tsx
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import App from '../src/App';
+
+// Mock the page components to isolate routing logic
+vi.mock('../src/pages/HomePage', () => ({
+ default: () => Home Page
,
+}));
+
+vi.mock('../src/pages/PropertyDetailPage', () => ({
+ default: () => Property Detail Page
,
+}));
+
+vi.mock('../src/pages/ContactPage', () => ({
+ default: () => Contact Page
,
+}));
+
+describe('App Routing', () => {
+ it('renders HomePage at /', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('home-page')).toBeDefined();
+ });
+
+ it('renders PropertyDetailPage at /property/:id', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('property-detail-page')).toBeDefined();
+ });
+
+ it('renders ContactPage at /contact', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('contact-page')).toBeDefined();
+ });
+
+ it('renders NotFoundPage for unknown routes', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Page Not Found')).toBeDefined();
+ expect(screen.getByText('Back to Home')).toBeDefined();
+ expect(screen.getByText('Contact Us')).toBeDefined();
+ });
+
+ it('renders 404 text for /random-page', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('404')).toBeDefined();
+ });
+});
diff --git a/tests/test_models.ts b/tests/test_models.ts
new file mode 100644
index 0000000..bd78742
--- /dev/null
+++ b/tests/test_models.ts
@@ -0,0 +1,163 @@
+/**
+ * Tests for TypeScript data models and mock data helpers.
+ *
+ * Run with: npx vitest run tests/test_models.ts
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { Property, Agent, Neighborhood, ContactFormData } from '../src/types/models';
+import { MOCK_PROPERTIES, getPropertyBySlug, getPropertiesByStatus, getFeaturedProperties } from '../src/data/mockProperties';
+import { MOCK_AGENTS, getAgentById } from '../src/data/mockAgents';
+import { MOCK_NEIGHBORHOODS, getNeighborhoodById, getNeighborhoodBySlug } from '../src/data/mockNeighborhoods';
+
+describe('Mock Properties', () => {
+ it('should contain 6 properties', () => {
+ expect(MOCK_PROPERTIES).toHaveLength(6);
+ });
+
+ it('each property should have required fields', () => {
+ MOCK_PROPERTIES.forEach((property: Property) => {
+ expect(property.id).toBeTruthy();
+ expect(property.title).toBeTruthy();
+ expect(property.slug).toBeTruthy();
+ expect(property.price).toBeGreaterThan(0);
+ expect(property.address).toBeTruthy();
+ expect(property.city).toBeTruthy();
+ expect(property.state).toBeTruthy();
+ expect(property.zipCode).toBeTruthy();
+ expect(property.images.length).toBeGreaterThan(0);
+ expect(property.agent).toBeDefined();
+ expect(property.neighborhood).toBeDefined();
+ });
+ });
+
+ it('getPropertyBySlug should return the correct property', () => {
+ const property = getPropertyBySlug('modern-farmhouse-open-floor-plan');
+ expect(property).toBeDefined();
+ expect(property!.id).toBe('prop-1');
+ });
+
+ it('getPropertyBySlug should return undefined for unknown slug', () => {
+ const property = getPropertyBySlug('non-existent-property');
+ expect(property).toBeUndefined();
+ });
+
+ it('getPropertiesByStatus should filter correctly', () => {
+ const forSale = getPropertiesByStatus('for-sale');
+ expect(forSale.length).toBeGreaterThan(0);
+ forSale.forEach((p) => expect(p.status).toBe('for-sale'));
+
+ const sold = getPropertiesByStatus('sold');
+ expect(sold.length).toBeGreaterThan(0);
+ sold.forEach((p) => expect(p.status).toBe('sold'));
+
+ const pending = getPropertiesByStatus('pending');
+ expect(pending.length).toBeGreaterThan(0);
+ pending.forEach((p) => expect(p.status).toBe('pending'));
+ });
+
+ it('getFeaturedProperties should return the first 3 properties', () => {
+ const featured = getFeaturedProperties();
+ expect(featured).toHaveLength(3);
+ expect(featured[0].id).toBe('prop-1');
+ expect(featured[1].id).toBe('prop-2');
+ expect(featured[2].id).toBe('prop-3');
+ });
+});
+
+describe('Mock Agents', () => {
+ it('should contain 4 agents', () => {
+ expect(MOCK_AGENTS).toHaveLength(4);
+ });
+
+ it('each agent should have required fields', () => {
+ MOCK_AGENTS.forEach((agent: Agent) => {
+ expect(agent.id).toBeTruthy();
+ expect(agent.name).toBeTruthy();
+ expect(agent.email).toBeTruthy();
+ expect(agent.phone).toBeTruthy();
+ expect(agent.photo).toBeTruthy();
+ expect(agent.rating).toBeGreaterThan(0);
+ expect(agent.rating).toBeLessThanOrEqual(5);
+ });
+ });
+
+ it('getAgentById should return the correct agent', () => {
+ const agent = getAgentById('agent-1');
+ expect(agent).toBeDefined();
+ expect(agent!.name).toBe('James Mitchell');
+ });
+
+ it('getAgentById should return undefined for unknown id', () => {
+ const agent = getAgentById('unknown-agent');
+ expect(agent).toBeUndefined();
+ });
+});
+
+describe('Mock Neighborhoods', () => {
+ it('should contain 4 neighborhoods', () => {
+ expect(MOCK_NEIGHBORHOODS).toHaveLength(4);
+ });
+
+ it('each neighborhood should have required fields', () => {
+ MOCK_NEIGHBORHOODS.forEach((neighborhood: Neighborhood) => {
+ expect(neighborhood.id).toBeTruthy();
+ expect(neighborhood.name).toBeTruthy();
+ expect(neighborhood.slug).toBeTruthy();
+ expect(neighborhood.city).toBeTruthy();
+ expect(neighborhood.state).toBeTruthy();
+ expect(neighborhood.image).toBeTruthy();
+ expect(neighborhood.averagePrice).toBeGreaterThan(0);
+ expect(neighborhood.walkScore).toBeGreaterThanOrEqual(0);
+ expect(neighborhood.walkScore).toBeLessThanOrEqual(100);
+ });
+ });
+
+ it('getNeighborhoodById should return the correct neighborhood', () => {
+ const neighborhood = getNeighborhoodById('neighborhood-1');
+ expect(neighborhood).toBeDefined();
+ expect(neighborhood!.name).toBe('Maple Heights');
+ });
+
+ it('getNeighborhoodById should return undefined for unknown id', () => {
+ const neighborhood = getNeighborhoodById('unknown');
+ expect(neighborhood).toBeUndefined();
+ });
+
+ it('getNeighborhoodBySlug should return the correct neighborhood', () => {
+ const neighborhood = getNeighborhoodBySlug('riverside-district');
+ expect(neighborhood).toBeDefined();
+ expect(neighborhood!.id).toBe('neighborhood-2');
+ });
+
+ it('getNeighborhoodBySlug should return undefined for unknown slug', () => {
+ const neighborhood = getNeighborhoodBySlug('nonexistent');
+ expect(neighborhood).toBeUndefined();
+ });
+});
+
+describe('ContactFormData type', () => {
+ it('should accept valid contact form data', () => {
+ const formData: ContactFormData = {
+ name: 'John Doe',
+ email: 'john@example.com',
+ phone: '555-123-4567',
+ message: 'I am interested in a property.',
+ preferredContact: 'email',
+ };
+ expect(formData.name).toBe('John Doe');
+ expect(formData.propertyId).toBeUndefined();
+ });
+
+ it('should accept contact form data with optional propertyId', () => {
+ const formData: ContactFormData = {
+ name: 'Jane Smith',
+ email: 'jane@example.com',
+ phone: '555-987-6543',
+ message: 'Tell me more about this property.',
+ propertyId: 'prop-1',
+ preferredContact: 'phone',
+ };
+ expect(formData.propertyId).toBe('prop-1');
+ });
+});
diff --git a/tests/types/models.test.ts b/tests/types/models.test.ts
new file mode 100644
index 0000000..3e5d128
--- /dev/null
+++ b/tests/types/models.test.ts
@@ -0,0 +1,159 @@
+/**
+ * Type-level and runtime tests for domain model type definitions.
+ *
+ * Verifies that the TypeScript interfaces compile correctly and that
+ * mock data conforms to the expected shapes.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type {
+ Property,
+ Agent,
+ Neighborhood,
+ ContactFormData,
+ PropertyType,
+ PropertyStatus,
+ PreferredContact,
+} from '../../src/types/models';
+
+describe('Type definitions', () => {
+ it('should allow a valid Property object', () => {
+ const property: Property = {
+ id: 'test-001',
+ title: 'Test Property',
+ slug: 'test-property',
+ price: 500000,
+ address: '123 Main St',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78701',
+ propertyType: 'house',
+ bedrooms: 3,
+ bathrooms: 2,
+ squareFeet: 1800,
+ lotSize: 0.25,
+ yearBuilt: 2020,
+ description: 'A lovely test property.',
+ features: ['Pool', 'Garage'],
+ images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=600&fit=crop'],
+ featured: true,
+ agentId: 'agent-001',
+ neighborhoodId: 'hood-001',
+ listingDate: '2024-01-01',
+ status: 'for-sale',
+ };
+
+ expect(property.id).toBe('test-001');
+ expect(property.bedrooms).toBe(3);
+ expect(property.featured).toBe(true);
+ });
+
+ it('should allow a Property without optional lotSize', () => {
+ const condo: Property = {
+ id: 'test-002',
+ title: 'Test Condo',
+ slug: 'test-condo',
+ price: 300000,
+ address: '456 High St #10',
+ city: 'Austin',
+ state: 'TX',
+ zipCode: '78702',
+ propertyType: 'condo',
+ bedrooms: 1,
+ bathrooms: 1,
+ squareFeet: 750,
+ yearBuilt: 2019,
+ description: 'A test condo.',
+ features: [],
+ images: [],
+ featured: false,
+ agentId: 'agent-002',
+ neighborhoodId: 'hood-002',
+ listingDate: '2024-02-01',
+ status: 'pending',
+ };
+
+ expect(condo.lotSize).toBeUndefined();
+ expect(condo.propertyType).toBe('condo');
+ });
+
+ it('should allow a valid Agent object', () => {
+ const agent: Agent = {
+ id: 'agent-test',
+ name: 'Test Agent',
+ title: 'Listing Agent',
+ phone: '(512) 555-0000',
+ email: 'test@example.com',
+ photo: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?w=400&h=400&fit=crop',
+ bio: 'A great agent.',
+ specialties: ['Luxury'],
+ propertiesCount: 5,
+ rating: 4.5,
+ socialLinks: { linkedin: 'https://linkedin.com/in/test' },
+ };
+
+ expect(agent.name).toBe('Test Agent');
+ expect(agent.rating).toBe(4.5);
+ });
+
+ it('should allow a valid Neighborhood object', () => {
+ const neighborhood: Neighborhood = {
+ id: 'hood-test',
+ name: 'Test Neighborhood',
+ slug: 'test-neighborhood',
+ city: 'Austin',
+ state: 'TX',
+ description: 'A wonderful area.',
+ image: 'https://images.unsplash.com/photo-1444723121867-7a241cacace9?w=800&h=600&fit=crop',
+ averagePrice: 600000,
+ walkScore: 75,
+ transitScore: 50,
+ highlights: ['Parks', 'Schools'],
+ featuredPropertyIds: ['prop-001'],
+ };
+
+ expect(neighborhood.slug).toBe('test-neighborhood');
+ expect(neighborhood.walkScore).toBe(75);
+ });
+
+ it('should allow a valid ContactFormData object', () => {
+ const formData: ContactFormData = {
+ name: 'John Doe',
+ email: 'john@example.com',
+ phone: '(512) 555-1234',
+ message: 'I am interested in a property.',
+ propertyId: 'prop-001',
+ preferredContact: 'email',
+ };
+
+ expect(formData.name).toBe('John Doe');
+ expect(formData.preferredContact).toBe('email');
+ });
+
+ it('should allow ContactFormData without optional fields', () => {
+ const formData: ContactFormData = {
+ name: 'Jane Smith',
+ email: 'jane@example.com',
+ message: 'General inquiry.',
+ preferredContact: 'either',
+ };
+
+ expect(formData.phone).toBeUndefined();
+ expect(formData.propertyId).toBeUndefined();
+ });
+
+ it('should accept all valid PropertyType values', () => {
+ const types: PropertyType[] = ['house', 'condo', 'townhouse', 'apartment', 'land'];
+ expect(types).toHaveLength(5);
+ });
+
+ it('should accept all valid PropertyStatus values', () => {
+ const statuses: PropertyStatus[] = ['for-sale', 'pending', 'sold'];
+ expect(statuses).toHaveLength(3);
+ });
+
+ it('should accept all valid PreferredContact values', () => {
+ const contacts: PreferredContact[] = ['email', 'phone', 'either'];
+ expect(contacts).toHaveLength(3);
+ });
+});
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..eca37cc
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting / Strict mode */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src", "tests"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..426eda2
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" }
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..b90387b
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss(),
+ ],
+ server: {
+ port: 5173,
+ open: true,
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: true,
+ },
+});