diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d79111b --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +build/ + +# Lock files (generated by package manager) +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Python +__pycache__/ +*.pyc +*.egg-info/ +.venv/ + +# Vite +*.local diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a391860 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,150 @@ +# Maddie | Luxury Real Estate — Architecture Document + +## Overview + +A single-page luxury real estate landing page built with React, TypeScript, +Tailwind CSS, and Vite. The design emphasizes elegance through a refined +color palette, premium typography, and smooth interactions. + +--- + +## File & Folder Structure + +``` +├── index.html +├── package.json +├── tsconfig.json +├── tailwind.config.js +├── postcss.config.js +├── vite.config.ts +├── ARCHITECTURE.md +├── SETUP.md +├── public/ +│ └── vite.svg +├── src/ +│ ├── main.tsx # React entry point +│ ├── App.tsx # Root component +│ ├── index.css # Global styles & Tailwind directives +│ ├── components/ +│ │ ├── Header.tsx # Navigation bar +│ │ ├── Hero.tsx # Hero section with CTA +│ │ ├── About.tsx # Agent profile / about section +│ │ ├── RecentSales.tsx # Property cards grid +│ │ ├── PropertyCard.tsx # Individual property card +│ │ ├── Contact.tsx # Contact form section +│ │ └── Footer.tsx # Site footer +│ ├── utils/ +│ │ └── scrollTo.ts # Smooth scroll-to-id utility +│ └── types/ +│ └── index.ts # Shared TypeScript interfaces +└── tests/ + ├── setup.ts # Test setup (jsdom, jest-dom) + ├── test_index_html.test.ts + ├── test_main.test.tsx + └── test_index_css.test.ts +``` + +--- + +## Component Tree + +``` +App +├── Header (sticky nav, logo, nav links, CTA button) +├── Hero (background image, headline, sub-headline, dual CTAs) +├── About (agent photo, bio, statistics row) +├── RecentSales (section heading, PropertyCard[] grid) +│ └── PropertyCard (image, address, price, beds/baths/sqft) +├── Contact (form: name, email, phone, message, submit) +└── Footer (logo, links, social icons, copyright) +``` + +--- + +## Design Tokens + +### Color Palette + +| Token | Hex | Usage | +| -------------- | --------- | ------------------------------ | +| cream | `#FFFDF7` | Page background | +| cream-dark | `#FFF8F0` | Card backgrounds, alternation | +| slate-900 | `#0F172A` | Heading text | +| slate-700 | `#334155` | Body text | +| slate-500 | `#64748B` | Secondary/muted text | +| slate-400 | `#94A3B8` | Placeholder, borders | +| gold | `#C8A951` | Primary accent, buttons | +| gold-dark | `#B8963E` | Hover states | +| gold-medium | `#D4B968` | Gradient mid-point | +| gold-light | `#E8D5A3` | Gradient highlight, decorative | + +### Gold Gradient + +```css +background: linear-gradient(135deg, #C8A951 0%, #E8D5A3 50%, #D4B968 100%); +``` + +--- + +## Typography + +| Role | Font Family | Weights | +| -------- | ----------------- | ------------------ | +| Headings | Playfair Display | 400, 500, 600, 700 | +| Body | Inter | 300, 400, 500, 600, 700 | + +Fonts are loaded via Google Fonts `` in `index.html` with +`preconnect` hints for optimal loading. + +--- + +## Responsive Breakpoints + +| Name | Min Width | Usage | +| ---- | --------- | -------------------------- | +| sm | 640px | Tablet portrait | +| md | 768px | Tablet landscape | +| lg | 1024px | Desktop | +| xl | 1280px | Large desktop | + +--- + +## Section Order + +1. **Header / Navigation** — Sticky top, glass-morphism effect +2. **Hero** — Full-viewport, background image, gradient overlay +3. **About / Profile** — Two-column layout (image + bio) +4. **Recent Sales** — 3-column responsive grid of property cards +5. **Contact** — Centered form with gold accent border +6. **Footer** — Dark background, multi-column links + +--- + +## Smooth Scroll Implementation + +1. CSS `scroll-behavior: smooth` on `` element +2. React utility `scrollTo(id: string)` that calls + `document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })` +3. Navigation links use `href="#section-id"` with `onClick` calling the + scroll utility for enhanced control + +--- + +## Curated Image Sources (Unsplash) + +- **Hero**: Luxury home exterior — `https://images.unsplash.com/photo-1600596542815-ffad4c1539a9` +- **About**: Professional headshot — `https://images.unsplash.com/photo-1573496359142-b8d87734a5a2` +- **Property 1**: Modern villa — `https://images.unsplash.com/photo-1600585154340-be6161a56a0c` +- **Property 2**: Penthouse — `https://images.unsplash.com/photo-1600607687939-ce8a6c25118c` +- **Property 3**: Estate — `https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea` + +--- + +## Tailwind Customizations + +See `tailwind.config.js` for full configuration. Key extensions: + +- Custom color tokens (cream, gold variants) +- Font family aliases (`font-playfair`, `font-inter`) +- `max-w-8xl` (88rem) for wide section containers +- Custom spacing values for fine-tuned layouts diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..f529cf4 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,104 @@ +# Running the Project + +**Maddie — Luxury Real Estate Agent Landing Page built with React + Vite + Tailwind CSS.** + +--- + +## Prerequisites + +Before you begin, ensure you have the following installed on your system: + +- **Node.js** (v18 or later recommended) — [https://nodejs.org/](https://nodejs.org/) +- **npm** (v9 or later, bundled with Node.js) + +You can verify your installation by running: + +```bash +node --version +npm --version +``` + +--- + +## Install Dependencies + +From the project root directory, install all required packages: + +```bash +npm install +``` + +This will read `package.json` and install both production dependencies (React, React DOM) and development dependencies (Vite, Tailwind CSS, PostCSS, Autoprefixer, and the Vite React plugin). + +--- + +## Run the Development Server + +Start the local development server with hot module replacement (HMR): + +```bash +npm run dev +``` + +Once started, the application will be available at: + +``` +http://localhost:3000 +``` + +The dev server watches for file changes and automatically reloads the browser. Tailwind CSS classes are processed on the fly via PostCSS. + +--- + +## Build for Production + +Create an optimized, minified production build: + +```bash +npm run build +``` + +The output is written to the `dist/` directory. This bundle is ready for deployment to any static hosting provider (Vercel, Netlify, AWS S3, GitHub Pages, etc.). + +--- + +## Preview the Production Build + +After building, you can preview the production bundle locally: + +```bash +npm run preview +``` + +This starts a local static file server that serves the contents of `dist/`, allowing you to verify the production build before deploying. + +--- + +## Project Structure Overview + +``` +. +├── index.html # Root HTML entry point with Google Fonts +├── package.json # Project manifest and scripts +├── vite.config.js # Vite configuration with React plugin +├── tailwind.config.js # Tailwind CSS design tokens and customizations +├── postcss.config.js # PostCSS plugin configuration +├── src/ +│ ├── main.jsx # React entry point +│ ├── index.css # Global styles and Tailwind directives +│ └── ... # React components and assets +├── dist/ # Production build output (generated) +└── RUNNING.md # This file +``` + +--- + +## Tech Stack + +| Technology | Purpose | +| --------------- | ------------------------------ | +| React | UI component library | +| Vite | Build tool and dev server | +| Tailwind CSS | Utility-first CSS framework | +| PostCSS | CSS processing pipeline | +| Autoprefixer | Vendor prefix management | diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..0b00989 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,43 @@ +# Setup Instructions + +## Prerequisites + +- Node.js >= 18 +- npm >= 9 + +## Install Dependencies + +```bash +npm install +``` + +This will generate `package-lock.json` and `node_modules/` — both are +git-ignored and must not be hand-written. + +## Development Server + +```bash +npm run dev +``` + +Opens the dev server at `http://localhost:3000`. + +## Run Tests + +```bash +npm test +``` + +## Production Build + +```bash +npm run build +``` + +Output is written to `dist/`. + +## Preview Production Build + +```bash +npm run preview +``` diff --git a/frontend/src/components/About.test.tsx b/frontend/src/components/About.test.tsx new file mode 100644 index 0000000..08dc18a --- /dev/null +++ b/frontend/src/components/About.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import About, { AboutProps, TrustBadgeData } from './About'; + +const defaultBadges: TrustBadgeData[] = [ + { icon: '🏠', label: 'Licensed Agent' }, + { icon: '🏡', label: '200+ Homes Sold' }, + { icon: '⭐', label: '5★ Rated' }, +]; + +const defaultProps: AboutProps = { + sectionLabel: 'About Maddie', + bioText: + 'Maddie is a dedicated real estate professional with over a decade of experience helping families find their dream homes.', + trustBadges: defaultBadges, +}; + +describe('About', () => { + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders the section with the correct id', () => { + const { container } = render(); + const section = container.querySelector('section#about'); + expect(section).toBeTruthy(); + }); + + it('renders a custom sectionId when provided', () => { + const { container } = render( + + ); + const section = container.querySelector('section#profile'); + expect(section).toBeTruthy(); + }); + + it('renders the section label text', () => { + render(); + expect(screen.getByText('About Maddie')).toBeTruthy(); + }); + + it('renders the bio paragraph text', () => { + render(); + const bio = screen.getByTestId('bio-text'); + expect(bio.textContent).toBe(defaultProps.bioText); + }); + + it('renders all trust badges', () => { + render(); + expect(screen.getByText('Licensed Agent')).toBeTruthy(); + expect(screen.getByText('200+ Homes Sold')).toBeTruthy(); + expect(screen.getByText('5★ Rated')).toBeTruthy(); + }); + + it('renders the trust badges container', () => { + render(); + const badgesContainer = screen.getByTestId('trust-badges'); + expect(badgesContainer.children.length).toBe(3); + }); + + it('renders the avatar placeholder when no image URL is provided', () => { + render(); + const placeholder = screen.getByTestId('avatar-placeholder'); + expect(placeholder).toBeTruthy(); + }); + + it('renders the gold gradient border on the avatar', () => { + render(); + const border = screen.getByTestId('avatar-border'); + expect(border.style.background).toContain('#C8A951'); + }); + + it('renders an image when avatarImageUrl is provided', () => { + render( + + ); + const img = screen.getByAlt('Maddie headshot'); + expect(img).toBeTruthy(); + expect(img.getAttribute('src')).toBe('https://example.com/headshot.jpg'); + }); + + it('does not render placeholder when avatarImageUrl is given', () => { + render( + + ); + expect(screen.queryByTestId('avatar-placeholder')).toBeNull(); + }); + + it('renders badge icons as role=img with accessible labels', () => { + render(); + const badgeIcons = screen.getAllByRole('img'); + expect(badgeIcons.length).toBe(3); + expect(badgeIcons[0].getAttribute('aria-label')).toBe('Licensed Agent'); + }); + + it('handles empty trust badges array gracefully', () => { + render(); + const badgesContainer = screen.getByTestId('trust-badges'); + expect(badgesContainer.children.length).toBe(0); + }); +}); diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx new file mode 100644 index 0000000..cc1d1a4 --- /dev/null +++ b/frontend/src/components/About.tsx @@ -0,0 +1,120 @@ +import React from 'react'; + +export interface TrustBadgeData { + icon: string; + label: string; +} + +export interface AboutProps { + sectionId?: string; + sectionLabel: string; + bioText: string; + avatarImageUrl?: string; + avatarAlt?: string; + trustBadges: TrustBadgeData[]; +} + +function TrustBadge({ icon, label }: TrustBadgeData) { + return ( +
+ + {icon} + + + {label} + +
+ ); +} + +export default function About({ + sectionId = 'about', + sectionLabel, + bioText, + avatarImageUrl, + avatarAlt = 'Professional headshot', + trustBadges, +}: AboutProps) { + return ( +
+
+
+ {/* Left column: Avatar */} +
+
+
+ {avatarImageUrl ? ( + {avatarAlt} + ) : ( +
+ +
+ )} +
+
+
+ + {/* Right column: Text content */} +
+

+ {sectionLabel} +

+ +
+
+
+ ); +} diff --git a/frontend/src/components/Button.test.tsx b/frontend/src/components/Button.test.tsx new file mode 100644 index 0000000..b33b970 --- /dev/null +++ b/frontend/src/components/Button.test.tsx @@ -0,0 +1,131 @@ +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')).toBeInTheDocument(); + }); + + it('renders children text correctly', () => { + render(); + expect(screen.getByRole('button')).toHaveTextContent('Get Started'); + }); + + it('renders as a ); + const el = screen.getByRole('button'); + expect(el.tagName).toBe('BUTTON'); + }); + + it('renders as an element when href is provided', () => { + render(); + const el = screen.getByRole('button'); + expect(el.tagName).toBe('A'); + expect(el).toHaveAttribute('href', 'https://example.com'); + }); + + it('applies primary variant classes by default', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('bg-gold'); + expect(el.className).toContain('text-slate-900'); + }); + + it('applies secondary variant classes', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('border-gold'); + expect(el.className).toContain('bg-transparent'); + }); + + it('applies outline variant classes', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('border-slate-500'); + expect(el.className).toContain('bg-transparent'); + }); + + it('includes hover scale animation class', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('hover:scale-105'); + }); + + it('includes focus-visible ring classes for accessibility', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('focus-visible:ring-2'); + expect(el.className).toContain('focus-visible:ring-gold'); + }); + + it('calls onClick when clicked', () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick on anchor variant when clicked', () => { + const handleClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); + render( + + ); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('applies custom className', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('mt-4'); + expect(el.className).toContain('custom-class'); + }); + + it('renders disabled state correctly', () => { + render(); + const el = screen.getByRole('button') as HTMLButtonElement; + expect(el).toBeDisabled(); + expect(el.className).toContain('disabled:opacity-50'); + }); + + it('renders as button (not anchor) when disabled even with href', () => { + render( + + ); + const el = screen.getByRole('button'); + expect(el.tagName).toBe('BUTTON'); + expect(el).toBeDisabled(); + }); + + it('applies aria-label when provided', () => { + render(); + const el = screen.getByRole('button'); + expect(el).toHaveAttribute('aria-label', 'Close dialog'); + }); + + it('sets button type attribute', () => { + render(); + const el = screen.getByRole('button') as HTMLButtonElement; + expect(el.type).toBe('submit'); + }); + + it('defaults button type to "button"', () => { + render(); + const el = screen.getByRole('button') as HTMLButtonElement; + expect(el.type).toBe('button'); + }); + + it('includes transition classes for smooth animation', () => { + render(); + const el = screen.getByRole('button'); + expect(el.className).toContain('transition-all'); + expect(el.className).toContain('duration-300'); + expect(el.className).toContain('transform'); + }); +}); diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..b351199 --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +export interface ButtonProps { + /** Visual variant of the button */ + variant?: 'primary' | 'secondary' | 'outline'; + /** Button content */ + children: React.ReactNode; + /** Click handler (ignored when href is provided) */ + onClick?: (e: React.MouseEvent) => void; + /** If provided, renders an tag instead of + ); +}; + +export default Button; diff --git a/frontend/src/components/Contact.test.tsx b/frontend/src/components/Contact.test.tsx new file mode 100644 index 0000000..105aeeb --- /dev/null +++ b/frontend/src/components/Contact.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import Contact from './Contact'; + +// Mock child components to isolate Contact tests +vi.mock('./SectionWrapper', () => ({ + default: ({ children, id, background }: { children: React.ReactNode; id: string; background: string }) => ( +
+ {children} +
+ ), +})); + +vi.mock('./SocialIcons', () => ({ + default: (props: Record) => ( +
+ ), +})); + +vi.mock('./ContactForm', () => ({ + default: ({ onSubmit }: { onSubmit?: Function }) => ( +
+ ), +})); + +describe('Contact', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('section-wrapper')).toBeInTheDocument(); + }); + + it('uses SectionWrapper with id="contact" and slate background', () => { + render(); + const wrapper = screen.getByTestId('section-wrapper'); + expect(wrapper).toHaveAttribute('data-id', 'contact'); + expect(wrapper).toHaveAttribute('data-background', 'slate'); + }); + + it('renders the default heading', () => { + render(); + expect(screen.getByTestId('contact-heading')).toHaveTextContent('Get In Touch'); + }); + + it('renders a custom heading when provided', () => { + render(); + expect(screen.getByTestId('contact-heading')).toHaveTextContent('Contact Me'); + }); + + it('renders the default description', () => { + render(); + const desc = screen.getByTestId('contact-description'); + expect(desc).toBeInTheDocument(); + expect(desc.textContent).toContain('looking to buy, sell'); + }); + + it('renders a custom description when provided', () => { + render(); + expect(screen.getByTestId('contact-description')).toHaveTextContent('Custom contact description'); + }); + + it('renders the default phone number', () => { + render(); + const phoneEl = screen.getByTestId('contact-phone'); + expect(phoneEl).toHaveTextContent('555-123-4567'); + expect(phoneEl).toHaveAttribute('href', 'tel:5551234567'); + }); + + it('renders a custom phone number when provided', () => { + render(); + const phoneEl = screen.getByTestId('contact-phone'); + expect(phoneEl).toHaveTextContent('800-555-0000'); + expect(phoneEl).toHaveAttribute('href', 'tel:8005550000'); + }); + + it('renders the default email address', () => { + render(); + const emailEl = screen.getByTestId('contact-email'); + expect(emailEl).toHaveTextContent('maddie@realestate.com'); + expect(emailEl).toHaveAttribute('href', 'mailto:maddie@realestate.com'); + }); + + it('renders a custom email when provided', () => { + render(); + const emailEl = screen.getByTestId('contact-email'); + expect(emailEl).toHaveTextContent('custom@test.com'); + expect(emailEl).toHaveAttribute('href', 'mailto:custom@test.com'); + }); + + it('renders the SocialIcons component', () => { + render(); + expect(screen.getByTestId('social-icons')).toBeInTheDocument(); + }); + + it('passes socialLinks to SocialIcons', () => { + const links = { facebook: 'https://facebook.com/test', instagram: 'https://instagram.com/test' }; + render(); + const iconsEl = screen.getByTestId('social-icons'); + const passedProps = JSON.parse(iconsEl.getAttribute('data-props') || '{}'); + expect(passedProps.facebook).toBe('https://facebook.com/test'); + expect(passedProps.instagram).toBe('https://instagram.com/test'); + }); + + it('renders the ContactForm component', () => { + render(); + expect(screen.getByTestId('contact-form')).toBeInTheDocument(); + }); + + it('passes onFormSubmit callback to ContactForm', () => { + const handleSubmit = vi.fn(); + render(); + const formEl = screen.getByTestId('contact-form'); + expect(formEl).toHaveAttribute('data-has-submit', 'true'); + }); + + it('renders without onFormSubmit callback', () => { + render(); + const formEl = screen.getByTestId('contact-form'); + expect(formEl).toHaveAttribute('data-has-submit', 'false'); + }); + + it('renders both columns (info and form)', () => { + render(); + expect(screen.getByTestId('contact-social-icons')).toBeInTheDocument(); + expect(screen.getByTestId('contact-form-wrapper')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Contact.tsx b/frontend/src/components/Contact.tsx new file mode 100644 index 0000000..1f072a8 --- /dev/null +++ b/frontend/src/components/Contact.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import SectionWrapper from './SectionWrapper'; +import SocialIcons from './SocialIcons'; +import ContactForm from './ContactForm'; + +export interface ContactProps { + /** Section heading */ + heading?: string; + /** Descriptive paragraph text */ + description?: string; + /** Phone number to display */ + phone?: string; + /** Email address to display */ + email?: string; + /** Props forwarded to SocialIcons */ + socialLinks?: { + facebook?: string; + instagram?: string; + linkedin?: string; + twitter?: string; + }; + /** Callback when the contact form is submitted */ + onFormSubmit?: (data: { name: string; email: string; phone: string; message: string }) => void; +} + +const Contact: React.FC = ({ + heading = 'Get In Touch', + description = "Whether you're looking to buy, sell, or simply have questions about the local market, I'd love to hear from you. Reach out today and let's start a conversation about your real estate goals.", + phone = '555-123-4567', + email = 'maddie@realestate.com', + socialLinks = {}, + onFormSubmit, +}) => { + return ( + +
+ {/* Left Column - Contact Info */} +
+

+ {heading} +

+ +

+ {description} +

+ +
+ {/* Phone */} +
+ + + {phone} + +
+ + {/* Email */} +
+ + + {email} + +
+
+ + {/* Social Icons */} +
+ +
+
+ + {/* Right Column - Contact Form */} +
+ +
+
+
+ ); +}; + +export default Contact; diff --git a/frontend/src/components/ContactForm.test.tsx b/frontend/src/components/ContactForm.test.tsx new file mode 100644 index 0000000..5ad343f --- /dev/null +++ b/frontend/src/components/ContactForm.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ContactForm from './ContactForm'; + +// Mock the Button component since ContactForm imports it +vi.mock('./Button', () => ({ + default: ({ label, type, variant }: { label: string; type?: string; variant?: string }) => ( + + ), +})); + +describe('ContactForm', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('contact-form')).toBeInTheDocument(); + }); + + it('renders all four input fields with default labels', () => { + render(); + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByLabelText('Phone')).toBeInTheDocument(); + expect(screen.getByLabelText('Message')).toBeInTheDocument(); + }); + + it('renders custom labels when provided', () => { + render( + + ); + expect(screen.getByLabelText('Full Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email Address')).toBeInTheDocument(); + expect(screen.getByLabelText('Phone Number')).toBeInTheDocument(); + expect(screen.getByLabelText('Your Message')).toBeInTheDocument(); + }); + + it('renders title and subtitle when provided', () => { + render(); + expect(screen.getByText('Get In Touch')).toBeInTheDocument(); + expect(screen.getByText('We would love to hear from you')).toBeInTheDocument(); + }); + + it('does not render title or subtitle when not provided', () => { + render(); + const form = screen.getByTestId('contact-form'); + expect(form.querySelector('h2')).toBeNull(); + }); + + it('renders the submit button with custom text', () => { + render(); + expect(screen.getByText('Submit Now')).toBeInTheDocument(); + }); + + it('renders the submit button with default text', () => { + render(); + expect(screen.getByText('Send Message')).toBeInTheDocument(); + }); + + it('shows validation errors when submitting empty form', () => { + render(); + fireEvent.click(screen.getByText('Send Message')); + + const alerts = screen.getAllByRole('alert'); + expect(alerts).toHaveLength(4); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + expect(screen.getByText('Email is required')).toBeInTheDocument(); + expect(screen.getByText('Phone is required')).toBeInTheDocument(); + expect(screen.getByText('Message is required')).toBeInTheDocument(); + }); + + it('shows email format error for invalid email', () => { + render(); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'invalid-email' } }); + fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '555-1234' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello' } }); + fireEvent.click(screen.getByText('Send Message')); + + expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument(); + }); + + it('shows success message on valid submission', () => { + const handleSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); + fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '555-1234' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello there' } }); + fireEvent.click(screen.getByText('Send Message')); + + expect(screen.getByTestId('contact-form-success')).toBeInTheDocument(); + expect(screen.getByText('Thank you! Your message has been sent successfully.')).toBeInTheDocument(); + }); + + it('calls onSubmit callback with form data on valid submission', () => { + const handleSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); + fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '555-1234' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello there' } }); + fireEvent.click(screen.getByText('Send Message')); + + expect(handleSubmit).toHaveBeenCalledOnce(); + expect(handleSubmit).toHaveBeenCalledWith({ + name: 'John Doe', + email: 'john@example.com', + phone: '555-1234', + message: 'Hello there', + }); + }); + + it('renders custom success message', () => { + render(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Jane' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'jane@test.com' } }); + fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '123-4567' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hi' } }); + fireEvent.click(screen.getByText('Send Message')); + + expect(screen.getByText('Message received!')).toBeInTheDocument(); + }); + + it('allows sending another message after success', () => { + render(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Jane' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'jane@test.com' } }); + fireEvent.change(screen.getByLabelText('Phone'), { target: { value: '123-4567' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hi' } }); + fireEvent.click(screen.getByText('Send Message')); + + expect(screen.getByTestId('contact-form-success')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Send another message')); + + expect(screen.getByTestId('contact-form')).toBeInTheDocument(); + }); + + it('uses correct input types', () => { + render(); + expect(screen.getByLabelText('Name')).toHaveAttribute('type', 'text'); + expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'email'); + expect(screen.getByLabelText('Phone')).toHaveAttribute('type', 'tel'); + expect(screen.getByLabelText('Message').tagName.toLowerCase()).toBe('textarea'); + }); + + it('renders placeholders correctly', () => { + render( + + ); + expect(screen.getByPlaceholderText('Enter name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter phone')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter message')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ContactForm.tsx b/frontend/src/components/ContactForm.tsx new file mode 100644 index 0000000..e456ea2 --- /dev/null +++ b/frontend/src/components/ContactForm.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import Button from './Button'; + +export interface ContactFormProps { + /** Heading displayed above the form */ + title?: string; + /** Subheading or description text */ + subtitle?: string; + /** Label for the name field */ + nameLabel?: string; + /** Placeholder for the name field */ + namePlaceholder?: string; + /** Label for the email field */ + emailLabel?: string; + /** Placeholder for the email field */ + emailPlaceholder?: string; + /** Label for the phone field */ + phoneLabel?: string; + /** Placeholder for the phone field */ + phonePlaceholder?: string; + /** Label for the message field */ + messageLabel?: string; + /** Placeholder for the message field */ + messagePlaceholder?: string; + /** Text displayed on the submit button */ + submitButtonText?: string; + /** Success message shown after successful submission */ + successMessage?: string; + /** Optional callback fired on valid submission with form data */ + onSubmit?: (data: { name: string; email: string; phone: string; message: string }) => void; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const ContactForm: React.FC = ({ + title, + subtitle, + nameLabel = 'Name', + namePlaceholder = 'Your full name', + emailLabel = 'Email', + emailPlaceholder = 'you@example.com', + phoneLabel = 'Phone', + phonePlaceholder = '(555) 123-4567', + messageLabel = 'Message', + messagePlaceholder = 'How can I help you?', + submitButtonText = 'Send Message', + successMessage = 'Thank you! Your message has been sent successfully.', + onSubmit, +}) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [message, setMessage] = useState(''); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + const validate = (): Record => { + const errs: Record = {}; + if (!name.trim()) errs.name = 'Name is required'; + if (!email.trim()) { + errs.email = 'Email is required'; + } else if (!EMAIL_REGEX.test(email)) { + errs.email = 'Please enter a valid email address'; + } + if (!phone.trim()) errs.phone = 'Phone is required'; + if (!message.trim()) errs.message = 'Message is required'; + return errs; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const validationErrors = validate(); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length === 0) { + onSubmit?.({ name: name.trim(), email: email.trim(), phone: phone.trim(), message: message.trim() }); + setSubmitted(true); + setName(''); + setEmail(''); + setPhone(''); + setMessage(''); + } + }; + + const inputClasses = + 'w-full rounded-md border border-gray-300 bg-[#FFFDF7] px-4 py-3 text-slate-800 placeholder-slate-400 transition-all duration-200 focus:border-[#C8A951] focus:outline-none focus:ring-2 focus:ring-[#C8A951]/50'; + const errorClasses = 'mt-1 text-sm text-red-600'; + + if (submitted) { + return ( +
+
+ + + +
+

{successMessage}

+ +
+ ); + } + + return ( + + {title &&

{title}

} + {subtitle &&

{subtitle}

} + +
+ + setName(e.target.value)} placeholder={namePlaceholder} className={inputClasses} aria-required="true" /> + {errors.name &&

{errors.name}

} +
+ +
+ + setEmail(e.target.value)} placeholder={emailPlaceholder} className={inputClasses} aria-required="true" /> + {errors.email &&

{errors.email}

} +
+ +
+ + setPhone(e.target.value)} placeholder={phonePlaceholder} className={inputClasses} aria-required="true" /> + {errors.phone &&

{errors.phone}

} +
+ +
+ +