diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a6041f8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,87 @@ +# Real Estate Website — Architecture Document + +## Project Overview + +A modern real estate listing website built with React, TypeScript, Vite, and Tailwind CSS. + +## Project Structure + +``` +src/ +├── types/ +│ └── models.ts # TypeScript interfaces & type aliases +├── data/ +│ ├── mockProperties.ts # 12+ mock property listings +│ ├── mockAgents.ts # 3+ mock real estate agents +│ └── mockNeighborhoods.ts # 6 mock neighborhoods +├── components/ +│ ├── atoms/ # Buttons, badges, inputs, icons +│ ├── molecules/ # Cards, forms, nav items +│ └── organisms/ # Header, footer, property grid, hero +├── pages/ +│ ├── HomePage.tsx +│ ├── PropertiesPage.tsx +│ ├── PropertyDetailPage.tsx +│ ├── AgentsPage.tsx +│ ├── AgentDetailPage.tsx +│ ├── NeighborhoodsPage.tsx +│ ├── NeighborhoodDetailPage.tsx +│ └── ContactPage.tsx +├── hooks/ # Custom React hooks +├── utils/ # Formatting helpers (price, address) +├── App.tsx # Root component with React Router +├── main.tsx # Entry point +└── index.css # Tailwind directives & custom styles +``` + +## Data Models + +All domain models live in `src/types/models.ts`: + +| Model | Purpose | +| ---------------- | --------------------------------------------- | +| `Property` | A real estate listing with images, price, etc. | +| `Agent` | A real estate agent / broker | +| `Neighborhood` | A geographic area with aggregate stats | +| `ContactFormData`| Shape of the contact form submission | + +Supporting union types: `PropertyType`, `PropertyStatus`, `PreferredContact`. + +## Routing (React Router v6) + +| Path | Page | +| ----------------------------- | ------------------------ | +| `/` | HomePage | +| `/properties` | PropertiesPage | +| `/properties/:slug` | PropertyDetailPage | +| `/agents` | AgentsPage | +| `/agents/:id` | AgentDetailPage | +| `/neighborhoods` | NeighborhoodsPage | +| `/neighborhoods/:slug` | NeighborhoodDetailPage | +| `/contact` | ContactPage | + +## Mock Data Strategy + +- All images use real **Unsplash** photo IDs with the format: + `https://images.unsplash.com/photo-{ID}?w={W}&h={H}&fit=crop` +- Properties: exterior & interior photography +- Agents: professional headshot portraits +- Neighborhoods: aerial / cityscape / street-level imagery + +## Styling + +- **Tailwind CSS** utility-first approach +- Design tokens defined via `tailwind.config.js` `extend` section +- Colour palette: slate (neutrals), blue (primary), amber (accent) +- Typography: Inter (sans), Georgia fallback (serif headings) +- Responsive breakpoints: `sm` 640px, `md` 768px, `lg` 1024px, `xl` 1280px + +## Build Tooling + +| Tool | Purpose | +| ----------- | ---------------------- | +| Vite | Dev server & bundler | +| TypeScript | Static type checking | +| ESLint | Linting | +| Prettier | Code formatting | +| Vitest | Unit / integration tests | diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..1841543 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,59 @@ +# Running the Application + +This document describes how to install dependencies and run the real estate website locally. + +## Prerequisites + +- **Node.js** >= 18.x +- **npm** >= 9.x (ships with Node.js 18+) + +## Installation + +```bash +npm install +``` + +## Development Server + +Start the Vite development server with hot module replacement: + +```bash +npm run dev +``` + +The app will be available at [http://localhost:5173](http://localhost:5173) by default. + +## Production Build + +Create an optimised production build: + +```bash +npm run build +``` + +The output will be placed in the `dist/` directory. + +## Preview Production Build + +Serve the production build locally for testing: + +```bash +npm run preview +``` + +## Linting + +Run ESLint to check for code quality issues: + +```bash +npm run lint +``` + +## Routes + +| Path | Page | Description | +| ------------------ | ------------------- | ---------------------------- | +| `/` | HomePage | Property listings & hero | +| `/property/:id` | PropertyDetailPage | Individual property details | +| `/contact` | ContactPage | Contact form | +| `*` (catch-all) | NotFoundPage | Styled 404 page | diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..68e1fbb --- /dev/null +++ b/SETUP.md @@ -0,0 +1,36 @@ +# Setup Instructions + +## Prerequisites + +- Node.js >= 18 +- npm >= 9 (or pnpm / yarn) + +## Install Dependencies + +```bash +npm install +``` + +## Run Tests + +```bash +npx vitest run +``` + +## Run Tests in Watch Mode + +```bash +npx vitest +``` + +## Project Configuration + +The following lock files and generated directories are **not** checked into version control +and must be produced by the toolchain: + +- `node_modules/` +- `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` +- `dist/` +- `.vite/` + +Run `npm install` to generate them locally. diff --git a/frontend/src/components/AgentCard.test.tsx b/frontend/src/components/AgentCard.test.tsx new file mode 100644 index 0000000..7946732 --- /dev/null +++ b/frontend/src/components/AgentCard.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AgentCard from './AgentCard'; +import type { Agent } from '../types/models'; + +const mockAgent: Agent = { + id: 'agent-1', + name: 'Jane Doe', + title: 'Senior Real Estate Agent', + phone: '(555) 123-4567', + email: 'jane.doe@realty.com', + photo: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop', + bio: 'With over 15 years of experience in luxury real estate, Jane specializes in helping families find their dream homes in the greater metropolitan area.', + specialties: ['Luxury Homes', 'Waterfront Properties'], + propertiesCount: 42, + rating: 4.9, + socialLinks: { + linkedin: 'https://linkedin.com/in/janedoe', + }, +}; + +describe('AgentCard', () => { + it('renders without crashing', () => { + const onContact = vi.fn(); + render(); + expect(screen.getByTestId('agent-card')).toBeInTheDocument(); + }); + + it('displays the agent name', () => { + render(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); + + it('displays the agent title', () => { + render(); + expect(screen.getByText('Senior Real Estate Agent')).toBeInTheDocument(); + }); + + it('displays the agent phone number', () => { + render(); + const phoneLinks = screen.getAllByTestId('agent-phone'); + expect(phoneLinks.length).toBeGreaterThan(0); + expect(phoneLinks[0]).toHaveTextContent('(555) 123-4567'); + }); + + it('displays the agent email', () => { + render(); + const emailLinks = screen.getAllByTestId('agent-email'); + expect(emailLinks.length).toBeGreaterThan(0); + expect(emailLinks[0]).toHaveTextContent('jane.doe@realty.com'); + }); + + it('displays the agent bio', () => { + render(); + expect(screen.getByText(/With over 15 years/)).toBeInTheDocument(); + }); + + it('renders the agent headshot with correct src and alt', () => { + render(); + const img = screen.getByAlt('Jane Doe headshot') as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.src).toBe(mockAgent.photo); + }); + + it('calls onContact with the agent when button is clicked', () => { + const onContact = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('contact-button')); + expect(onContact).toHaveBeenCalledTimes(1); + expect(onContact).toHaveBeenCalledWith(mockAgent); + }); + + it('renders the Contact Agent button text', () => { + render(); + expect(screen.getByTestId('contact-button')).toHaveTextContent('Contact Agent'); + }); + + it('renders in vertical layout by default', () => { + render(); + const card = screen.getByTestId('agent-card'); + expect(card.className).toContain('text-center'); + }); + + it('renders in horizontal layout when specified', () => { + render(); + const card = screen.getByTestId('agent-card'); + expect(card.className).toContain('flex-row'); + expect(card.className).not.toContain('text-center'); + }); + + it('applies custom className', () => { + render( + + ); + const card = screen.getByTestId('agent-card'); + expect(card.className).toContain('my-custom-class'); + }); + + it('renders phone link with tel: href', () => { + render(); + const phoneLinks = screen.getAllByTestId('agent-phone'); + expect(phoneLinks[0].closest('a')).toHaveAttribute('href', 'tel:(555) 123-4567'); + }); + + it('renders email link with mailto: href', () => { + render(); + const emailLinks = screen.getAllByTestId('agent-email'); + expect(emailLinks[0].closest('a')).toHaveAttribute('href', 'mailto:jane.doe@realty.com'); + }); + + it('renders correctly with a different agent', () => { + const otherAgent: Agent = { + ...mockAgent, + id: 'agent-2', + name: 'John Smith', + title: 'Broker Associate', + phone: '(555) 987-6543', + email: 'john.smith@realty.com', + bio: 'John brings a decade of market expertise.', + }; + render(); + expect(screen.getByText('John Smith')).toBeInTheDocument(); + expect(screen.getByText('Broker Associate')).toBeInTheDocument(); + expect(screen.getByText(/John brings a decade/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AgentCard.tsx b/frontend/src/components/AgentCard.tsx new file mode 100644 index 0000000..9220b14 --- /dev/null +++ b/frontend/src/components/AgentCard.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import type { Agent } from '../types/models'; + +export interface AgentCardProps { + /** The agent data to display */ + agent: Agent; + /** Callback fired when the "Contact Agent" button is clicked */ + onContact: (agent: Agent) => void; + /** Layout orientation: vertical (sidebar) or horizontal */ + layout?: 'vertical' | 'horizontal'; + /** Optional additional CSS class names */ + className?: string; +} + +const AgentCard: React.FC = ({ + agent, + onContact, + layout = 'vertical', + className = '', +}) => { + const handleContact = () => { + onContact(agent); + }; + + if (layout === 'horizontal') { + return ( +
+
+ {`${agent.name} +
+ +
+

+ {agent.name} +

+

+ {agent.title} +

+

+ {agent.bio} +

+ +
+ +
+ +
+
+ ); + } + + return ( +
+
+ {`${agent.name} +
+ +

{agent.name}

+

{agent.title}

+ + + +

+ {agent.bio} +

+ + +
+ ); +}; + +export default AgentCard; diff --git a/frontend/src/components/AgentProfileSection.test.tsx b/frontend/src/components/AgentProfileSection.test.tsx new file mode 100644 index 0000000..1528121 --- /dev/null +++ b/frontend/src/components/AgentProfileSection.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AgentProfileSection, { Agent } from './AgentProfileSection'; + +const mockAgents: Agent[] = [ + { + id: '1', + name: 'Sarah Johnson', + title: 'Senior Real Estate Agent', + phone: '(555) 123-4567', + email: 'sarah@example.com', + photo: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop', + bio: 'Sarah has over 15 years of experience in residential real estate, specializing in luxury homes and waterfront properties.', + specialties: ['Luxury Homes', 'Waterfront'], + propertiesCount: 42, + rating: 4.9, + socialLinks: { + linkedin: 'https://linkedin.com/in/sarah', + twitter: 'https://twitter.com/sarah', + }, + }, + { + id: '2', + name: 'Michael Chen', + title: 'Buyer Specialist', + phone: '(555) 234-5678', + email: 'michael@example.com', + photo: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop', + bio: 'Michael is passionate about helping first-time buyers navigate the market with confidence.', + specialties: ['First-Time Buyers', 'Condos'], + propertiesCount: 28, + rating: 4.7, + }, + { + id: '3', + name: 'Emily Rodriguez', + title: 'Listing Agent', + phone: '(555) 345-6789', + email: 'emily@example.com', + photo: 'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=400&h=400&fit=crop', + bio: 'Emily consistently achieves above-asking-price sales with her innovative marketing strategies.', + specialties: ['Marketing', 'Staging'], + propertiesCount: 35, + rating: 4.8, + }, +]; + +describe('AgentProfileSection', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('agent-profile-section')).toBeInTheDocument(); + }); + + it('renders the default heading', () => { + render(); + expect(screen.getByText('Meet Our Agents')).toBeInTheDocument(); + }); + + it('renders a custom heading when provided', () => { + render(); + expect(screen.getByText('Our Team')).toBeInTheDocument(); + expect(screen.queryByText('Meet Our Agents')).not.toBeInTheDocument(); + }); + + it('renders the default intro paragraph', () => { + render(); + expect( + screen.getByText(/Our team of experienced real estate professionals/) + ).toBeInTheDocument(); + }); + + it('renders a custom intro paragraph when provided', () => { + const customIntro = 'Custom intro text for the section.'; + render(); + expect(screen.getByText(customIntro)).toBeInTheDocument(); + }); + + it('renders all agent cards', () => { + render(); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-3')).toBeInTheDocument(); + }); + + it('displays agent names', () => { + render(); + expect(screen.getByText('Sarah Johnson')).toBeInTheDocument(); + expect(screen.getByText('Michael Chen')).toBeInTheDocument(); + expect(screen.getByText('Emily Rodriguez')).toBeInTheDocument(); + }); + + it('displays agent titles', () => { + render(); + expect(screen.getByText('Senior Real Estate Agent')).toBeInTheDocument(); + expect(screen.getByText('Buyer Specialist')).toBeInTheDocument(); + expect(screen.getByText('Listing Agent')).toBeInTheDocument(); + }); + + it('displays agent bios', () => { + render(); + expect(screen.getByText(/Sarah has over 15 years/)).toBeInTheDocument(); + expect(screen.getByText(/Michael is passionate/)).toBeInTheDocument(); + }); + + it('displays agent specialties', () => { + render(); + expect(screen.getByText('Luxury Homes')).toBeInTheDocument(); + expect(screen.getByText('Waterfront')).toBeInTheDocument(); + expect(screen.getByText('First-Time Buyers')).toBeInTheDocument(); + expect(screen.getByText('Condos')).toBeInTheDocument(); + }); + + it('displays agent emails as links', () => { + render(); + const sarahEmail = screen.getByText('sarah@example.com'); + expect(sarahEmail).toBeInTheDocument(); + expect(sarahEmail.closest('a')).toHaveAttribute('href', 'mailto:sarah@example.com'); + }); + + it('displays agent phone numbers as links', () => { + render(); + const sarahPhone = screen.getByText('(555) 123-4567'); + expect(sarahPhone).toBeInTheDocument(); + expect(sarahPhone.closest('a')).toHaveAttribute('href', 'tel:(555) 123-4567'); + }); + + it('displays properties count for each agent', () => { + render(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('28')).toBeInTheDocument(); + expect(screen.getByText('35')).toBeInTheDocument(); + }); + + it('renders agent photos with correct alt text', () => { + render(); + const sarahImg = screen.getByAlt('Sarah Johnson') as HTMLImageElement; + expect(sarahImg).toBeInTheDocument(); + expect(sarahImg.src).toContain('unsplash'); + }); + + it('displays rating information', () => { + render(); + expect(screen.getByText('(4.9)')).toBeInTheDocument(); + expect(screen.getByText('(4.7)')).toBeInTheDocument(); + expect(screen.getByText('(4.8)')).toBeInTheDocument(); + }); + + it('shows a message when agents array is empty', () => { + render(); + expect(screen.getByTestId('no-agents-message')).toBeInTheDocument(); + expect(screen.getByText('No agents available at this time.')).toBeInTheDocument(); + }); + + it('renders social links when provided', () => { + render(); + const linkedinLink = screen.getByLabelText('Sarah Johnson LinkedIn'); + expect(linkedinLink).toHaveAttribute('href', 'https://linkedin.com/in/sarah'); + expect(linkedinLink).toHaveAttribute('target', '_blank'); + expect(linkedinLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders with a single agent', () => { + render(); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.queryByTestId('agent-card-2')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AgentProfileSection.tsx b/frontend/src/components/AgentProfileSection.tsx new file mode 100644 index 0000000..673f9b8 --- /dev/null +++ b/frontend/src/components/AgentProfileSection.tsx @@ -0,0 +1,144 @@ +import React from 'react'; + +export interface AgentSocialLinks { + linkedin?: string; + twitter?: string; + facebook?: string; + instagram?: string; +} + +export interface Agent { + id: string; + name: string; + title: string; + phone: string; + email: string; + photo: string; + bio: string; + specialties: string[]; + propertiesCount: number; + rating: number; + socialLinks?: AgentSocialLinks; +} + +export interface AgentProfileSectionProps { + agents: Agent[]; + heading?: string; + introParagraph?: string; +} + +function StarRating({ rating }: { rating: number }) { + const fullStars = Math.floor(rating); + const hasHalf = rating - fullStars >= 0.5; + const emptyStars = 5 - fullStars - (hasHalf ? 1 : 0); + + return ( +
+ {Array.from({ length: fullStars }).map((_, i) => ( + + ))} + {hasHalf && } + {Array.from({ length: emptyStars }).map((_, i) => ( + + ))} + ({rating.toFixed(1)}) +
+ ); +} + +function AgentCard({ agent }: { agent: Agent }) { + return ( +
+
+ {agent.name} +
+
+

{agent.name}

+

{agent.title}

+ +

{agent.bio}

+ {agent.specialties.length > 0 && ( +
+ {agent.specialties.map((specialty) => ( + + {specialty} + + ))} +
+ )} +
+

+ {agent.propertiesCount} properties +

+ + {agent.email} + + + {agent.phone} + +
+ {agent.socialLinks && Object.keys(agent.socialLinks).length > 0 && ( +
+ {agent.socialLinks.linkedin && ( + + + + )} + {agent.socialLinks.twitter && ( + + + + )} +
+ )} +
+
+ ); +} + +const AgentProfileSection: React.FC = ({ + agents, + heading = 'Meet Our Agents', + introParagraph = 'Our team of experienced real estate professionals is dedicated to helping you find the perfect property. With deep local knowledge and a commitment to exceptional service, our agents are here to guide you every step of the way.', +}) => { + return ( +
+
+
+

{heading}

+

{introParagraph}

+
+ {agents.length === 0 ? ( +

+ No agents available at this time. +

+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default AgentProfileSection; diff --git a/frontend/src/components/Badge.test.tsx b/frontend/src/components/Badge.test.tsx new file mode 100644 index 0000000..f3ac0ab --- /dev/null +++ b/frontend/src/components/Badge.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Badge from './Badge'; +import type { BadgeVariant } from './Badge'; + +describe('Badge', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('badge')).toBeInTheDocument(); + }); + + it('displays the provided text', () => { + render(); + expect(screen.getByText('Featured')).toBeInTheDocument(); + }); + + it('applies default variant styles when no variant is specified', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('bg-gray-100'); + expect(badge.className).toContain('text-gray-800'); + }); + + it('applies featured variant styles', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('bg-amber-100'); + expect(badge.className).toContain('text-amber-800'); + }); + + it('applies new variant styles', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('bg-emerald-100'); + expect(badge.className).toContain('text-emerald-800'); + }); + + it('applies sale variant styles', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('bg-blue-100'); + expect(badge.className).toContain('text-blue-800'); + }); + + it('applies rent variant styles', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('bg-purple-100'); + expect(badge.className).toContain('text-purple-800'); + }); + + it('uses rounded-full pill style', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('rounded-full'); + }); + + it('merges additional className prop', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.className).toContain('mt-4'); + expect(badge.className).toContain('shadow-lg'); + }); + + it('does not add extra space when className is empty', () => { + render(); + const badge = screen.getByTestId('badge'); + // Ensure no trailing space + expect(badge.className).not.toMatch(/\s$/); + }); + + it('renders all variant types without errors', () => { + const variants: BadgeVariant[] = ['featured', 'new', 'sale', 'rent', 'default']; + variants.forEach((variant) => { + const { unmount } = render(); + expect(screen.getByText(`Test ${variant}`)).toBeInTheDocument(); + unmount(); + }); + }); + + it('renders as an inline-flex span element', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge.tagName).toBe('SPAN'); + expect(badge.className).toContain('inline-flex'); + }); +}); diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx new file mode 100644 index 0000000..f3ab994 --- /dev/null +++ b/frontend/src/components/Badge.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +export type BadgeVariant = 'featured' | 'new' | 'sale' | 'rent' | 'default'; + +export interface BadgeProps { + /** The text content displayed inside the badge */ + text: string; + /** Visual variant that determines the color scheme */ + variant?: BadgeVariant; + /** Additional CSS classes to merge with the badge */ + className?: string; +} + +const variantStyles: Record = { + featured: + 'bg-amber-100 text-amber-800 border border-amber-300', + new: + 'bg-emerald-100 text-emerald-800 border border-emerald-300', + sale: + 'bg-blue-100 text-blue-800 border border-blue-300', + rent: + 'bg-purple-100 text-purple-800 border border-purple-300', + default: + 'bg-gray-100 text-gray-800 border border-gray-300', +}; + +const Badge: React.FC = ({ + text, + variant = 'default', + className = '', +}) => { + const baseStyles = + 'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold leading-tight whitespace-nowrap'; + const colorStyles = variantStyles[variant] || variantStyles.default; + + return ( + + {text} + + ); +}; + +export default Badge; diff --git a/frontend/src/components/Button.test.tsx b/frontend/src/components/Button.test.tsx new file mode 100644 index 0000000..1230ee8 --- /dev/null +++ b/frontend/src/components/Button.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Button from './Button'; + +describe('Button', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); + }); + + it('renders children correctly', () => { + render(); + expect(screen.getByText('Submit Form')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const handleClick = vi.fn(); + render( + + ); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('applies disabled attribute', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('defaults to type="button"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it('accepts type="submit"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + }); + + it('accepts type="reset"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'reset'); + }); + + it('applies primary variant classes by default', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('bg-indigo-700'); + expect(button.className).toContain('text-white'); + }); + + it('applies secondary variant classes', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('border-2'); + expect(button.className).toContain('border-indigo-700'); + expect(button.className).toContain('text-indigo-700'); + }); + + it('applies ghost variant classes', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('bg-transparent'); + expect(button.className).toContain('text-indigo-700'); + expect(button.className).not.toContain('border-2'); + }); + + it('applies sm size classes', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('px-3'); + expect(button.className).toContain('py-1.5'); + expect(button.className).toContain('text-sm'); + }); + + it('applies md size classes by default', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('px-5'); + expect(button.className).toContain('py-2.5'); + expect(button.className).toContain('text-base'); + }); + + it('applies lg size classes', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('px-7'); + expect(button.className).toContain('py-3.5'); + expect(button.className).toContain('text-lg'); + }); + + it('appends custom className', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('mt-4'); + expect(button.className).toContain('w-full'); + }); + + it('renders JSX children', () => { + render( + + ); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + expect(screen.getByText('Star')).toBeInTheDocument(); + }); + + it('includes transition classes for hover/focus states', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('transition-all'); + expect(button.className).toContain('duration-200'); + }); + + it('includes focus ring classes on primary variant', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('focus:ring-2'); + expect(button.className).toContain('focus:ring-indigo-500'); + }); + + it('includes focus ring classes on secondary variant', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('focus:ring-2'); + expect(button.className).toContain('focus:ring-indigo-500'); + }); + + it('includes focus ring classes on ghost variant', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('focus:ring-2'); + expect(button.className).toContain('focus:ring-indigo-500'); + }); +}); diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..f1bf3db --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +export interface ButtonProps { + /** Visual style variant */ + variant?: 'primary' | 'secondary' | 'ghost'; + /** Button size */ + size?: 'sm' | 'md' | 'lg'; + /** Button content */ + children: React.ReactNode; + /** Click handler */ + onClick?: (event: React.MouseEvent) => void; + /** Additional CSS classes */ + className?: string; + /** Whether the button is disabled */ + disabled?: boolean; + /** HTML button type attribute */ + type?: 'button' | 'submit' | 'reset'; +} + +const variantClasses: Record, string> = { + primary: [ + 'bg-indigo-700 text-white', + 'hover:bg-indigo-800', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', + 'active:bg-indigo-900', + 'disabled:bg-indigo-300 disabled:cursor-not-allowed', + 'shadow-md hover:shadow-lg', + ].join(' '), + secondary: [ + 'bg-transparent text-indigo-700 border-2 border-indigo-700', + 'hover:bg-indigo-50 hover:border-indigo-800 hover:text-indigo-800', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', + 'active:bg-indigo-100', + 'disabled:border-indigo-300 disabled:text-indigo-300 disabled:cursor-not-allowed disabled:hover:bg-transparent', + ].join(' '), + ghost: [ + 'bg-transparent text-indigo-700', + 'hover:bg-indigo-50 hover:text-indigo-800', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', + 'active:bg-indigo-100', + 'disabled:text-indigo-300 disabled:cursor-not-allowed disabled:hover:bg-transparent', + ].join(' '), +}; + +const sizeClasses: Record, string> = { + sm: 'px-3 py-1.5 text-sm font-medium rounded', + md: 'px-5 py-2.5 text-base font-semibold rounded-md', + lg: 'px-7 py-3.5 text-lg font-semibold rounded-lg', +}; + +const Button: React.FC = ({ + variant = 'primary', + size = 'md', + children, + onClick, + className = '', + disabled = false, + type = 'button', +}) => { + const baseClasses = + 'inline-flex items-center justify-center transition-all duration-200 ease-in-out tracking-wide'; + + const combinedClasses = [ + baseClasses, + variantClasses[variant], + sizeClasses[size], + className, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +}; + +export default Button; diff --git a/frontend/src/components/ContactForm.test.tsx b/frontend/src/components/ContactForm.test.tsx new file mode 100644 index 0000000..c294072 --- /dev/null +++ b/frontend/src/components/ContactForm.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ContactForm from './ContactForm'; + +// Minimal stubs for Input and Button if not already provided +vi.mock('./Input', () => ({ + default: (props: React.InputHTMLAttributes) => ( + + ), +})); + +vi.mock('./Button', () => ({ + default: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +const PROPERTY_OPTIONS = ['Sunset Villa', 'Downtown Condo', 'Lake House']; + +function fillForm() { + fireEvent.change(screen.getByTestId('input-name'), { + target: { name: 'name', value: 'Jane Doe' }, + }); + fireEvent.change(screen.getByTestId('input-email'), { + target: { name: 'email', value: 'jane@example.com' }, + }); + fireEvent.change(screen.getByTestId('input-phone'), { + target: { name: 'phone', value: '555-1234' }, + }); + fireEvent.change(screen.getByRole('combobox'), { + target: { name: 'propertyInterest', value: 'Downtown Condo' }, + }); + fireEvent.change(screen.getByPlaceholderText('Your message...'), { + target: { name: 'message', value: 'I am interested' }, + }); +} + +describe('ContactForm', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByText('Send Message')).toBeInTheDocument(); + }); + + it('displays agent name when agentName prop is provided', () => { + render(); + expect(screen.getByTestId('agent-label')).toHaveTextContent('John Smith'); + }); + + it('does not display agent label when agentName is not provided', () => { + render(); + expect(screen.queryByTestId('agent-label')).not.toBeInTheDocument(); + }); + + it('pre-fills property interest when propertyTitle is provided', () => { + render( + + ); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('Sunset Villa'); + }); + + it('shows validation errors when submitting empty form', async () => { + render(); + fireEvent.click(screen.getByText('Send Message')); + + const alerts = screen.getAllByRole('alert'); + expect(alerts.length).toBeGreaterThanOrEqual(4); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + expect(screen.getByText('Email is required')).toBeInTheDocument(); + expect(screen.getByText('Phone number is required')).toBeInTheDocument(); + expect(screen.getByText('Message is required')).toBeInTheDocument(); + }); + + it('shows email format error for invalid email', () => { + render(); + + fireEvent.change(screen.getByTestId('input-email'), { + target: { name: 'email', value: 'not-an-email' }, + }); + fireEvent.click(screen.getByText('Send Message')); + + expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument(); + }); + + it('calls onSubmit and shows success message on valid submission', () => { + const handleSubmit = vi.fn(); + render( + + ); + + fillForm(); + fireEvent.click(screen.getByText('Send Message')); + + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Jane Doe', + email: 'jane@example.com', + phone: '555-1234', + message: 'I am interested', + propertyInterest: 'Downtown Condo', + preferredContact: 'email', + }) + ); + + expect(screen.getByTestId('success-message')).toHaveTextContent( + 'Your message has been sent successfully!' + ); + }); + + it('allows changing preferred contact method to phone', () => { + const handleSubmit = vi.fn(); + render( + + ); + + fillForm(); + fireEvent.click(screen.getByLabelText('Phone')); + fireEvent.click(screen.getByText('Send Message')); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ preferredContact: 'phone' }) + ); + }); + + it('dismisses success message when close button is clicked', () => { + render(); + + fillForm(); + fireEvent.click(screen.getByText('Send Message')); + expect(screen.getByTestId('success-message')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Dismiss success message')); + expect(screen.queryByTestId('success-message')).not.toBeInTheDocument(); + }); + + it('clears field-level error when user types into that field', () => { + render(); + + fireEvent.click(screen.getByText('Send Message')); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('input-name'), { + target: { name: 'name', value: 'A' }, + }); + expect(screen.queryByText('Name is required')).not.toBeInTheDocument(); + }); + + it('renders all property options in the dropdown', () => { + render(); + const options = screen.getByRole('combobox').querySelectorAll('option'); + // +1 for the placeholder option + expect(options.length).toBe(PROPERTY_OPTIONS.length + 1); + }); + + it('defaults preferred contact method to email', () => { + render(); + const emailRadio = screen.getByLabelText('Email') as HTMLInputElement; + const phoneRadio = screen.getByLabelText('Phone') as HTMLInputElement; + expect(emailRadio.checked).toBe(true); + expect(phoneRadio.checked).toBe(false); + }); +}); diff --git a/frontend/src/components/ContactForm.tsx b/frontend/src/components/ContactForm.tsx new file mode 100644 index 0000000..29d1f9e --- /dev/null +++ b/frontend/src/components/ContactForm.tsx @@ -0,0 +1,231 @@ +import React, { useState, useCallback } from 'react'; +import Input from './Input'; +import Button from './Button'; + +export interface ContactFormProps { + /** Optional property title to pre-fill the property interest field */ + propertyTitle?: string; + /** Optional agent name to display in the form header */ + agentName?: string; + /** List of property options for the interest dropdown */ + propertyOptions?: string[]; + /** Callback fired with form data on valid submission */ + onSubmit?: (data: ContactFormData) => void; +} + +export interface ContactFormData { + name: string; + email: string; + phone: string; + message: string; + propertyInterest: string; + preferredContact: 'email' | 'phone'; +} + +interface FormErrors { + name?: string; + email?: string; + phone?: string; + message?: string; + propertyInterest?: string; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const INITIAL_DATA: ContactFormData = { + name: '', + email: '', + phone: '', + message: '', + propertyInterest: '', + preferredContact: 'email', +}; + +export default function ContactForm({ + propertyTitle, + agentName, + propertyOptions = [], + onSubmit, +}: ContactFormProps) { + const [formData, setFormData] = useState({ + ...INITIAL_DATA, + propertyInterest: propertyTitle ?? '', + }); + const [errors, setErrors] = useState({}); + const [submitted, setSubmitted] = useState(false); + + const validate = useCallback((): FormErrors => { + const errs: FormErrors = {}; + if (!formData.name.trim()) errs.name = 'Name is required'; + if (!formData.email.trim()) { + errs.email = 'Email is required'; + } else if (!EMAIL_REGEX.test(formData.email)) { + errs.email = 'Please enter a valid email address'; + } + if (!formData.phone.trim()) errs.phone = 'Phone number is required'; + if (!formData.message.trim()) errs.message = 'Message is required'; + if (!formData.propertyInterest.trim()) + errs.propertyInterest = 'Please select a property of interest'; + return errs; + }, [formData]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + if (errors[name as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + setErrors({}); + onSubmit?.(formData); + setSubmitted(true); + setFormData({ ...INITIAL_DATA, propertyInterest: propertyTitle ?? '' }); + }; + + const dismissSuccess = () => setSubmitted(false); + + const dropdownOptions = propertyTitle + ? [propertyTitle, ...propertyOptions.filter((o) => o !== propertyTitle)] + : propertyOptions; + + return ( +
+ {agentName && ( +

+ Contact {agentName} +

+ )} + + {submitted && ( +
+ Your message has been sent successfully! + +
+ )} + +
+ + {errors.name &&

{errors.name}

} +
+ +
+ + {errors.email &&

{errors.email}

} +
+ +
+ + {errors.phone &&

{errors.phone}

} +
+ +
+ + {errors.propertyInterest && ( +

{errors.propertyInterest}

+ )} +
+ +
+ Preferred Contact Method +
+ + +
+
+ +
+