From 54c40575b8bdbc11214a59474d2601e7c7cbbb3d Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:17:18 +0000 Subject: [PATCH 01/21] feat: Frontend project setup: Vite + React + Tailwind + config fil Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: b0655a6b-9925-4ead-a30e-2209c0e730cc Agent: builder --- .gitignore | 36 +++++ ARCHITECTURE.md | 157 ++++++++++++++++++++ SETUP.md | 47 ++++++ index.html | 31 ++++ package.json | 27 ++++ postcss.config.js | 6 + src/App.tsx | 126 +++++++++++++++++ src/hooks/useSmoothScroll.ts | 25 ++++ src/index.css | 69 +++++++++ src/main.tsx | 22 +++ src/types/index.ts | 65 +++++++++ src/utils/scrollTo.ts | 20 +++ src/vite-env.d.ts | 1 + tailwind.config.js | 63 +++++++++ tests/__init__.py | 0 tests/test_config.py | 267 +++++++++++++++++++++++++++++++++++ tsconfig.json | 31 ++++ tsconfig.node.json | 22 +++ vite.config.js | 21 +++ 19 files changed, 1036 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 SETUP.md create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/App.tsx create mode 100644 src/hooks/useSmoothScroll.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/types/index.ts create mode 100644 src/utils/scrollTo.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.js 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..d0e86c5 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,157 @@ +# Maddie Real Estate Landing Page — Architecture Document + +## Overview + +A single-page luxury real estate landing page built with React, Vite, TypeScript, and Tailwind CSS. The page showcases a real estate agent's brand, recent sales, and contact information with a warm, elegant aesthetic. + +--- + +## File & Folder Structure + +``` +/ +├── index.html # Root HTML with Google Fonts & meta tags +├── package.json # Dependencies and scripts +├── vite.config.js # Vite config with React plugin & path aliases +├── tailwind.config.js # Tailwind design tokens +├── postcss.config.js # PostCSS plugins (tailwindcss, autoprefixer) +├── tsconfig.json # TypeScript compiler options +├── tsconfig.node.json # TypeScript config for Node (Vite config) +├── ARCHITECTURE.md # This file +├── SETUP.md # Setup instructions +├── src/ +│ ├── main.tsx # React entry point +│ ├── App.tsx # Root application component +│ ├── index.css # Global styles & Tailwind directives +│ ├── vite-env.d.ts # Vite client type declarations +│ ├── components/ +│ │ ├── Header.tsx # Fixed navigation bar +│ │ ├── Hero.tsx # Full-width hero section +│ │ ├── About.tsx # About / agent profile section +│ │ ├── RecentSales.tsx # Property cards grid +│ │ ├── PropertyCard.tsx # Individual property card +│ │ ├── Contact.tsx # Contact form / CTA section +│ │ ├── Footer.tsx # Site footer +│ │ └── ScrollToTop.tsx # Scroll-to-top utility button +│ ├── hooks/ +│ │ └── useSmoothScroll.ts # Hook for smooth scroll-to-id navigation +│ ├── utils/ +│ │ └── scrollTo.ts # Utility function for smooth scrolling +│ └── types/ +│ └── index.ts # Shared TypeScript interfaces +└── tests/ + └── test_config.py # Validation tests for config files +``` + +--- + +## Component Tree + +``` +App +├── Header +│ └── Nav links (smooth scroll anchors) +├── Hero +│ └── CTA Button +├── About +│ └── Profile image + bio text +├── RecentSales +│ └── PropertyCard[] (grid of 3–6 cards) +├── Contact +│ └── Contact form / CTA +├── Footer +│ └── Social links, copyright +└── ScrollToTop +``` + +--- + +## Design Tokens + +### Color Palette + +| Token | Hex | Usage | +|-------------------|-----------|--------------------------------------| +| cream | `#FDF8F0` | Primary background | +| cream-light | `#FFFDF7` | Card backgrounds, subtle contrast | +| cream-dark | `#F5EDE0` | Borders, dividers | +| slate-900 | `#1E293B` | Primary headings | +| slate-700 | `#334155` | Body text | +| slate-500 | `#64748B` | Secondary text | +| slate-400 | `#94A3B8` | Muted text, placeholders | +| gold | `#C9A84C` | Primary accent | +| gold-light | `#D4B968` | Hover states | +| gold-lighter | `#E8D5A3` | Subtle accent backgrounds | +| gold-dark | `#B8943F` | Active states | +| warm-white | `#FAF7F2` | Alternate section background | + +### Typography + +| Element | Font Family | Weights | Sizes (desktop) | +|----------------|-------------------|------------------|-----------------------| +| h1 | Playfair Display | 700 | 4xl–6xl | +| h2 | Playfair Display | 600, 700 | 3xl–4xl | +| h3 | Playfair Display | 500, 600 | 2xl–3xl | +| h4–h6 | Playfair Display | 500 | xl–2xl | +| body | Inter | 300, 400, 500 | base–lg | +| button | Inter | 500, 600 | sm–base | +| caption | Inter | 400 | sm | + +Fonts are loaded via Google Fonts CDN in `index.html`. + +### Responsive Breakpoints + +| Breakpoint | Min Width | Usage | +|------------|-----------|--------------------------------| +| sm | 640px | Mobile landscape | +| md | 768px | Tablet portrait | +| lg | 1024px | Tablet landscape / small laptop| +| xl | 1280px | Desktop | +| 2xl | 1536px | Large desktop | + +These are Tailwind defaults — no overrides needed. + +--- + +## Section Order + +1. **Header / Nav** — Fixed top bar with logo and smooth-scroll nav links +2. **Hero** — Full-viewport hero with background image, heading, and CTA +3. **About / Profile** — Agent photo + bio with split layout +4. **Recent Sales** — Grid of property cards with images, price, details +5. **Contact** — Contact form or CTA block +6. **Footer** — Branding, social links, legal + +--- + +## Image Strategy + +Curated Unsplash images (free to use): + +- **Hero**: `https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1920&q=80` (luxury home exterior) +- **About**: `https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=800&q=80` (professional portrait) +- **Property 1**: `https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600&q=80` +- **Property 2**: `https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600&q=80` +- **Property 3**: `https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=600&q=80` + +--- + +## Smooth Scroll Implementation + +1. `html { scroll-behavior: smooth; }` in `index.html` and `src/index.css` +2. `scrollTo.ts` utility: `document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })` +3. `useSmoothScroll` hook wraps the utility for use in nav links +4. Each section has an `id` attribute matching the nav link href + +--- + +## Tailwind Customizations + +See `tailwind.config.js` for the full configuration. Key extensions: + +- **colors**: cream, gold, warm-white palettes added alongside Tailwind slate +- **fontFamily**: `playfair` → `['Playfair Display', 'serif']`, `inter` → `['Inter', 'sans-serif']` +- **spacing**: `18` (4.5rem), `88` (22rem), `128` (32rem) for hero/section sizing +- **maxWidth**: `8xl` (88rem) for wide section containers +- **animation**: `fade-in`, `slide-up` for entrance animations +- **keyframes**: Custom keyframes for the above animations diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..6696427 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,47 @@ +# Setup Instructions + +## Prerequisites + +- Node.js >= 18.x +- npm >= 9.x (or pnpm / yarn) + +## Installation + +```bash +# Navigate to project root +cd . + +# Install dependencies +npm install +``` + +## Development + +```bash +# Start the Vite dev server on port 3000 +npm run dev +``` + +## Build + +```bash +# Create production build in dist/ +npm run build + +# Preview production build locally +npm run preview +``` + +## Testing + +```bash +# Run config validation tests (requires Python 3.8+ with pytest) +pip install pytest +pytest tests/ +``` + +## Notes + +- Do NOT commit `node_modules/`, `dist/`, or lock files (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`). +- Lock files are generated automatically by your package manager during `npm install`. +- The `tsconfig.json` and `tsconfig.node.json` files are hand-written and should be committed. diff --git a/index.html b/index.html new file mode 100644 index 0000000..755b4b7 --- /dev/null +++ b/index.html @@ -0,0 +1,31 @@ + + + + + + + + + Maddie | Luxury Real Estate + + + + + + + + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..150bfbd --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "maddie-realestate", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Luxury real estate landing page built with React, Vite, and Tailwind CSS", + "scripts": { + "dev": "vite --port 3000", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3754781 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,126 @@ +/** + * Root application component. + * + * Composes all page sections in the correct order: + * Header → Hero → About → Recent Sales → Contact → Footer + */ +function App(): JSX.Element { + return ( +
+ {/* Header */} + + + {/* Hero Section */} +
+
+
+

+ Your Dream Home Awaits +

+

+ Specializing in luxury properties with a personal touch. + Let's find the perfect place for you. +

+ +
+
+
+ + {/* About Section */} +
+
+

About Maddie

+

+ With over a decade of experience in luxury real estate, I help + families find their perfect home. My commitment to personalized + service sets me apart. +

+
+
+ + {/* Recent Sales Section */} +
+
+

Recent Sales

+

+ A selection of recently sold properties. +

+
+ {/* Property cards will be added in subsequent phases */} +
+
+

Coming Soon

+

+ Property listings will appear here. +

+
+
+
+
+ + {/* Contact Section */} +
+
+

Let's Connect

+

+ Ready to start your journey? Reach out today and let's make + your real estate dreams a reality. +

+
+ + Contact Maddie + +
+
+
+ + {/* Footer */} +
+
+

+ Maddie +

+

+ © {new Date().getFullYear()} Maddie Real Estate. All rights + reserved. +

+
+
+
+ ); +} + +export default App; diff --git a/src/hooks/useSmoothScroll.ts b/src/hooks/useSmoothScroll.ts new file mode 100644 index 0000000..0333c4d --- /dev/null +++ b/src/hooks/useSmoothScroll.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { scrollToElement } from '@/utils/scrollTo'; + +/** + * Custom hook that returns a click handler for smooth-scrolling to a section. + * + * @param offset - Vertical offset in pixels to account for fixed headers. + * @returns A function that accepts a section ID and scrolls to it. + * + * @example + * ```tsx + * const scrollTo = useSmoothScroll(80); + * + * ``` + */ +export function useSmoothScroll(offset: number = 80): (sectionId: string) => void { + const scrollTo = useCallback( + (sectionId: string) => { + scrollToElement(sectionId, offset); + }, + [offset], + ); + + return scrollTo; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..fa802d5 --- /dev/null +++ b/src/index.css @@ -0,0 +1,69 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply font-inter text-slate-700 bg-cream; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-playfair text-slate-900; + } + + h1 { + @apply text-4xl font-bold leading-tight md:text-5xl lg:text-6xl; + } + + h2 { + @apply text-3xl font-semibold leading-snug md:text-4xl; + } + + h3 { + @apply text-2xl font-semibold leading-snug md:text-3xl; + } +} + +@layer components { + .section-container { + @apply mx-auto w-full max-w-8xl px-4 sm:px-6 lg:px-8; + } + + .section-padding { + @apply py-16 md:py-20 lg:py-24; + } + + .btn-primary { + @apply inline-flex items-center justify-center rounded-md bg-gold px-6 py-3 + font-inter text-sm font-semibold text-white transition-colors + duration-200 hover:bg-gold-dark focus:outline-none + focus:ring-2 focus:ring-gold focus:ring-offset-2 + focus:ring-offset-cream; + } + + .btn-secondary { + @apply inline-flex items-center justify-center rounded-md border + border-gold bg-transparent px-6 py-3 font-inter text-sm + font-semibold text-gold transition-colors duration-200 + hover:bg-gold hover:text-white focus:outline-none + focus:ring-2 focus:ring-gold focus:ring-offset-2 + focus:ring-offset-cream; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..12c131f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +/** + * Application entry point. + * Mounts the root React component into the DOM. + */ +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error( + 'Root element not found. Ensure index.html contains a
.', + ); +} + +ReactDOM.createRoot(rootElement).render( + + + , +); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9461c56 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,65 @@ +/** + * Shared TypeScript interfaces for the Maddie Real Estate landing page. + */ + +/** Represents a navigation link item. */ +export interface NavLink { + /** Display label for the link. */ + label: string; + /** Target section ID (without the '#' prefix). */ + href: string; +} + +/** Represents a property listing card. */ +export interface Property { + /** Unique identifier for the property. */ + id: string; + /** Street address of the property. */ + address: string; + /** Sale price formatted as a display string (e.g. "$1,250,000"). */ + price: string; + /** Number of bedrooms. */ + bedrooms: number; + /** Number of bathrooms. */ + bathrooms: number; + /** Square footage of the property. */ + sqft: number; + /** URL to the property image. */ + imageUrl: string; + /** Status of the listing. */ + status: 'sold' | 'active' | 'pending'; +} + +/** Represents the agent's profile information. */ +export interface AgentProfile { + /** Agent's full name. */ + name: string; + /** Professional title or designation. */ + title: string; + /** Short biography paragraph. */ + bio: string; + /** URL to the agent's headshot photo. */ + imageUrl: string; + /** Phone number for contact. */ + phone: string; + /** Email address for contact. */ + email: string; +} + +/** Props interface for the PropertyCard component. */ +export interface PropertyCardProps { + /** The property data to display. */ + property: Property; +} + +/** Contact form field values. */ +export interface ContactFormData { + /** Sender's full name. */ + name: string; + /** Sender's email address. */ + email: string; + /** Sender's phone number (optional). */ + phone?: string; + /** Message body. */ + message: string; +} diff --git a/src/utils/scrollTo.ts b/src/utils/scrollTo.ts new file mode 100644 index 0000000..6bac0c5 --- /dev/null +++ b/src/utils/scrollTo.ts @@ -0,0 +1,20 @@ +/** + * Smoothly scroll to an element identified by its DOM id. + * + * @param elementId - The id attribute of the target element (without '#'). + * @param offset - Optional vertical offset in pixels (e.g. for fixed headers). + */ +export function scrollToElement(elementId: string, offset: number = 0): void { + const element = document.getElementById(elementId); + if (!element) { + return; + } + + const elementPosition = element.getBoundingClientRect().top + window.scrollY; + const offsetPosition = elementPosition - offset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth', + }); +} 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/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..937e28c --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,63 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + ], + theme: { + extend: { + colors: { + cream: { + DEFAULT: '#FDF8F0', + light: '#FFFDF7', + dark: '#F5EDE0', + }, + gold: { + DEFAULT: '#C9A84C', + light: '#D4B968', + lighter: '#E8D5A3', + dark: '#B8943F', + }, + 'warm-white': '#FAF7F2', + slate: { + 400: '#94A3B8', + 500: '#64748B', + 700: '#334155', + 900: '#1E293B', + }, + }, + fontFamily: { + playfair: ['Playfair Display', 'serif'], + inter: ['Inter', 'sans-serif'], + }, + spacing: { + 18: '4.5rem', + 88: '22rem', + 128: '32rem', + }, + maxWidth: { + '8xl': '88rem', + }, + keyframes: { + 'fade-in': { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + 'slide-up': { + '0%': { opacity: '0', transform: 'translateY(20px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + 'slide-down': { + '0%': { opacity: '0', transform: 'translateY(-20px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + }, + animation: { + 'fade-in': 'fade-in 0.6s ease-out forwards', + 'slide-up': 'slide-up 0.6s ease-out forwards', + 'slide-down': 'slide-down 0.6s ease-out forwards', + }, + }, + }, + plugins: [], +}; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..98e17d4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,267 @@ +"""Validation tests for frontend configuration files. + +These tests verify that the project configuration files are correctly +structured and contain the expected design tokens and settings. +""" + +import json +import os +from pathlib import Path + +import pytest + +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def _read_file(relative_path: str) -> str: + """Read a file relative to the project root and return its content.""" + file_path = ROOT_DIR / relative_path + assert file_path.exists(), f"File not found: {relative_path}" + return file_path.read_text(encoding="utf-8") + + +class TestPackageJson: + """Tests for package.json structure and dependencies.""" + + @pytest.fixture() + def pkg(self) -> dict: + """Parse and return package.json as a dictionary.""" + content = _read_file("package.json") + return json.loads(content) + + def test_project_name(self, pkg: dict) -> None: + """Project should be named 'maddie-realestate'.""" + assert pkg["name"] == "maddie-realestate" + + def test_has_react_dependency(self, pkg: dict) -> None: + """React should be listed in dependencies.""" + assert "react" in pkg["dependencies"] + assert "react-dom" in pkg["dependencies"] + + def test_has_dev_dependencies(self, pkg: dict) -> None: + """Key dev dependencies should be present.""" + dev_deps = pkg["devDependencies"] + assert "vite" in dev_deps + assert "tailwindcss" in dev_deps + assert "postcss" in dev_deps + assert "autoprefixer" in dev_deps + assert "@vitejs/plugin-react" in dev_deps + assert "typescript" in dev_deps + + def test_has_scripts(self, pkg: dict) -> None: + """Essential npm scripts should be defined.""" + scripts = pkg["scripts"] + assert "dev" in scripts + assert "build" in scripts + assert "preview" in scripts + + def test_private_flag(self, pkg: dict) -> None: + """Package should be marked as private.""" + assert pkg["private"] is True + + +class TestIndexHtml: + """Tests for index.html content and meta tags.""" + + @pytest.fixture() + def html(self) -> str: + """Read and return index.html content.""" + return _read_file("index.html") + + def test_has_google_fonts_preconnect(self, html: str) -> None: + """Should have preconnect links for Google Fonts.""" + assert "fonts.googleapis.com" in html + assert "fonts.gstatic.com" in html + + def test_has_playfair_display_font(self, html: str) -> None: + """Should load Playfair Display from Google Fonts.""" + assert "Playfair+Display" in html or "Playfair Display" in html + + def test_has_inter_font(self, html: str) -> None: + """Should load Inter from Google Fonts.""" + assert "Inter" in html + + def test_has_smooth_scroll(self, html: str) -> None: + """Should have smooth scroll behavior on html element.""" + assert "scroll-behavior" in html + assert "smooth" in html + + def test_has_viewport_meta(self, html: str) -> None: + """Should have viewport meta tag.""" + assert 'name="viewport"' in html + + def test_has_description_meta(self, html: str) -> None: + """Should have a description meta tag.""" + assert 'name="description"' in html + + def test_has_root_div(self, html: str) -> None: + """Should have a root mount point div.""" + assert 'id="root"' in html + + def test_has_main_tsx_script(self, html: str) -> None: + """Should reference main.tsx as the entry module.""" + assert "src/main.tsx" in html + + +class TestTailwindConfig: + """Tests for tailwind.config.js design tokens.""" + + @pytest.fixture() + def config(self) -> str: + """Read and return tailwind.config.js content.""" + return _read_file("tailwind.config.js") + + def test_has_cream_color(self, config: str) -> None: + """Should define cream color token.""" + assert "#FDF8F0" in config + + def test_has_gold_color(self, config: str) -> None: + """Should define gold color token.""" + assert "#C9A84C" in config + + def test_has_warm_white_color(self, config: str) -> None: + """Should define warm-white color token.""" + assert "#FAF7F2" in config + + def test_has_slate_color(self, config: str) -> None: + """Should define slate color with value #334155.""" + assert "#334155" in config + + def test_has_playfair_font_family(self, config: str) -> None: + """Should configure Playfair Display font family.""" + assert "playfair" in config + assert "Playfair Display" in config + + def test_has_inter_font_family(self, config: str) -> None: + """Should configure Inter font family.""" + assert "inter" in config.lower() + assert "Inter" in config + + def test_has_content_paths(self, config: str) -> None: + """Should configure content paths for Tailwind purge.""" + assert "./index.html" in config + assert "./src/**/*.{js,ts,jsx,tsx}" in config + + def test_has_animations(self, config: str) -> None: + """Should define custom animations.""" + assert "fade-in" in config + assert "slide-up" in config + + def test_has_custom_spacing(self, config: str) -> None: + """Should extend spacing with custom values.""" + assert "4.5rem" in config + + +class TestPostcssConfig: + """Tests for postcss.config.js plugins.""" + + @pytest.fixture() + def config(self) -> str: + """Read and return postcss.config.js content.""" + return _read_file("postcss.config.js") + + def test_has_tailwindcss_plugin(self, config: str) -> None: + """Should include tailwindcss plugin.""" + assert "tailwindcss" in config + + def test_has_autoprefixer_plugin(self, config: str) -> None: + """Should include autoprefixer plugin.""" + assert "autoprefixer" in config + + +class TestViteConfig: + """Tests for vite.config.js settings.""" + + @pytest.fixture() + def config(self) -> str: + """Read and return vite.config.js content.""" + return _read_file("vite.config.js") + + def test_has_react_plugin(self, config: str) -> None: + """Should use the React Vite plugin.""" + assert "@vitejs/plugin-react" in config + assert "react()" in config + + def test_has_path_alias(self, config: str) -> None: + """Should define '@' path alias pointing to './src'.""" + assert "'@'" in config or '"@"' in config + assert "./src" in config + + def test_has_port_3000(self, config: str) -> None: + """Should configure dev server on port 3000.""" + assert "3000" in config + + +class TestTypeScriptConfig: + """Tests for tsconfig.json settings.""" + + @pytest.fixture() + def tsconfig(self) -> dict: + """Parse and return tsconfig.json as a dictionary.""" + content = _read_file("tsconfig.json") + return json.loads(content) + + def test_has_react_jsx(self, tsconfig: dict) -> None: + """Should use react-jsx for JSX transform.""" + assert tsconfig["compilerOptions"]["jsx"] == "react-jsx" + + def test_has_strict_mode(self, tsconfig: dict) -> None: + """Should enable strict mode.""" + assert tsconfig["compilerOptions"]["strict"] is True + + def test_has_path_alias(self, tsconfig: dict) -> None: + """Should define '@/*' path alias.""" + paths = tsconfig["compilerOptions"]["paths"] + assert "@/*" in paths + assert "./src/*" in paths["@/*"] + + def test_includes_src(self, tsconfig: dict) -> None: + """Should include the src directory.""" + assert "src" in tsconfig["include"] + + +class TestSourceFiles: + """Tests to verify essential source files exist and have correct content.""" + + def test_index_css_has_tailwind_directives(self) -> None: + """src/index.css should include Tailwind directives.""" + css = _read_file("src/index.css") + assert "@tailwind base" in css + assert "@tailwind components" in css + assert "@tailwind utilities" in css + + def test_index_css_has_base_typography(self) -> None: + """src/index.css should set base font families.""" + css = _read_file("src/index.css") + assert "font-inter" in css + assert "font-playfair" in css + + def test_main_tsx_exists(self) -> None: + """src/main.tsx should exist.""" + assert (ROOT_DIR / "src" / "main.tsx").exists() + + def test_app_tsx_exists(self) -> None: + """src/App.tsx should exist.""" + assert (ROOT_DIR / "src" / "App.tsx").exists() + + def test_vite_env_dts_exists(self) -> None: + """src/vite-env.d.ts should exist.""" + assert (ROOT_DIR / "src" / "vite-env.d.ts").exists() + + def test_types_index_exists(self) -> None: + """src/types/index.ts should exist.""" + assert (ROOT_DIR / "src" / "types" / "index.ts").exists() + + def test_scroll_utility_exists(self) -> None: + """src/utils/scrollTo.ts should exist.""" + assert (ROOT_DIR / "src" / "utils" / "scrollTo.ts").exists() + + def test_smooth_scroll_hook_exists(self) -> None: + """src/hooks/useSmoothScroll.ts should exist.""" + assert (ROOT_DIR / "src" / "hooks" / "useSmoothScroll.ts").exists() + + def test_app_has_all_sections(self) -> None: + """App.tsx should contain all major section IDs.""" + app = _read_file("src/App.tsx") + for section_id in ["hero", "about", "sales", "contact"]: + assert f'id="{section_id}"' in app, f"Missing section: {section_id}" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..131fb44 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "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": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..b4a98d2 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.js"] +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..e270b4e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + open: true, + }, + build: { + outDir: 'dist', + sourcemap: false, + }, +}); From 7ff4d6b1682cbd4d6c37f33656abc09637cd3b70 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:17:54 +0000 Subject: [PATCH 02/21] feat: Button component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: aba3b363-82f0-4631-8fe5-ae4f84e2d527 Agent: builder --- frontend/src/components/Button.test.tsx | 131 ++++++++++++++++++++++++ frontend/src/components/Button.tsx | 116 +++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 frontend/src/components/Button.test.tsx create mode 100644 frontend/src/components/Button.tsx 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; From b1698663e6e3f2551790c05b0dd08cc82e751c5f Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:17:54 +0000 Subject: [PATCH 03/21] feat: SVG Logo component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: ac6fcdc1-a50a-466b-a130-3fbd0877da64 Agent: builder --- frontend/src/components/Logo.test.tsx | 90 +++++++++++++++++ frontend/src/components/Logo.tsx | 136 ++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 frontend/src/components/Logo.test.tsx create mode 100644 frontend/src/components/Logo.tsx diff --git a/frontend/src/components/Logo.test.tsx b/frontend/src/components/Logo.test.tsx new file mode 100644 index 0000000..df44720 --- /dev/null +++ b/frontend/src/components/Logo.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Logo from './Logo'; + +describe('Logo', () => { + it('renders without crashing', () => { + render(); + const svg = screen.getByTestId('logo-svg'); + expect(svg).toBeDefined(); + }); + + it('renders the main text "Maddie"', () => { + render(); + const text = screen.getByTestId('logo-text'); + expect(text.textContent).toBe('Maddie'); + }); + + it('renders the subtitle "REAL ESTATE"', () => { + render(); + const subtitle = screen.getByTestId('logo-subtitle'); + expect(subtitle.textContent).toBe('REAL ESTATE'); + }); + + it('renders the house/key icon group', () => { + render(); + const icon = screen.getByTestId('logo-icon'); + expect(icon).toBeDefined(); + }); + + it('has correct aria-label for accessibility', () => { + render(); + const svg = screen.getByRole('img'); + expect(svg.getAttribute('aria-label')).toBe('Maddie Real Estate logo'); + }); + + it('defaults to sm size variant', () => { + render(); + const svg = screen.getByTestId('logo-svg'); + expect(svg.getAttribute('width')).toBe('140'); + expect(svg.getAttribute('height')).toBe('36'); + }); + + it('renders lg size variant with larger dimensions', () => { + render(); + const svg = screen.getByTestId('logo-svg'); + expect(svg.getAttribute('width')).toBe('260'); + expect(svg.getAttribute('height')).toBe('64'); + }); + + it('applies custom className', () => { + render(); + const svg = screen.getByTestId('logo-svg'); + expect(svg.classList.contains('my-custom-class')).toBe(true); + }); + + it('does not apply className when not provided', () => { + render(); + const svg = screen.getByTestId('logo-svg'); + // className should be empty string by default + expect(svg.getAttribute('class')).toBe(''); + }); + + it('contains a element for SEO and accessibility', () => { + render(<Logo />); + const svg = screen.getByTestId('logo-svg'); + const titleEl = svg.querySelector('title'); + expect(titleEl).not.toBeNull(); + expect(titleEl!.textContent).toBe('Maddie Real Estate'); + }); + + it('uses the gold color (#C9A84C) in the house path fill', () => { + render(<Logo />); + const icon = screen.getByTestId('logo-icon'); + const path = icon.querySelector('path'); + expect(path).not.toBeNull(); + expect(path!.getAttribute('fill')).toBe('#C9A84C'); + }); + + it('uses the slate color (#334155) for the main text', () => { + render(<Logo />); + const text = screen.getByTestId('logo-text'); + expect(text.getAttribute('fill')).toBe('#334155'); + }); + + it('uses the gold color (#C9A84C) for the subtitle', () => { + render(<Logo />); + const subtitle = screen.getByTestId('logo-subtitle'); + expect(subtitle.getAttribute('fill')).toBe('#C9A84C'); + }); +}); diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..500a9d6 --- /dev/null +++ b/frontend/src/components/Logo.tsx @@ -0,0 +1,136 @@ +import React from 'react'; + +export interface LogoProps { + /** Size variant controlling the overall dimensions */ + size?: 'sm' | 'lg'; + /** Optional additional CSS class names */ + className?: string; +} + +const sizeConfig = { + sm: { width: 140, height: 36, iconScale: 0.7, fontSize: 22, subtitleSize: 7 }, + lg: { width: 260, height: 64, iconScale: 1.2, fontSize: 40, subtitleSize: 11 }, +} as const; + +const GOLD = '#C9A84C'; +const SLATE = '#334155'; + +const Logo: React.FC<LogoProps> = ({ size = 'sm', className = '' }) => { + const config = sizeConfig[size]; + const { width, height, iconScale, fontSize, subtitleSize } = config; + + const iconWidth = 24 * iconScale; + const iconX = 4; + const iconCenterY = height / 2; + const textX = iconX + iconWidth + 6; + + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox={`0 0 ${width} ${height}`} + width={width} + height={height} + role="img" + aria-label="Maddie Real Estate logo" + className={className} + data-testid="logo-svg" + > + <title>Maddie Real Estate + + {/* House / Key Icon */} + + {/* House body */} + + {/* Key overlay (small key shape at bottom-right of house) */} + + + + + + + + + {/* "Maddie" text in elegant serif style */} + 8 ? 4 : 2)} + fontFamily="'Playfair Display', 'Georgia', 'Times New Roman', serif" + fontSize={fontSize} + fontWeight="600" + fill={SLATE} + dominantBaseline="central" + letterSpacing="1.5" + data-testid="logo-text" + > + Maddie + + + {/* Subtitle / tagline */} + 8 ? 4 : 2)} + fontFamily="'Inter', 'Helvetica Neue', Arial, sans-serif" + fontSize={subtitleSize} + fontWeight="400" + fill={GOLD} + letterSpacing="2.5" + textTransform="uppercase" + data-testid="logo-subtitle" + > + {'REAL ESTATE'} + + + {/* Gold accent line under the text */} + + + ); +}; + +export default Logo; From c64e045507e692d030b30e89c39fae9f405bffd0 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:18:15 +0000 Subject: [PATCH 04/21] feat: TrustBadge component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 4fb6e326-159c-491b-bd93-bc7b22ccfdc4 Agent: builder --- frontend/src/components/TrustBadge.test.tsx | 77 +++++++++++++++++++++ frontend/src/components/TrustBadge.tsx | 50 +++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 frontend/src/components/TrustBadge.test.tsx create mode 100644 frontend/src/components/TrustBadge.tsx diff --git a/frontend/src/components/TrustBadge.test.tsx b/frontend/src/components/TrustBadge.test.tsx new file mode 100644 index 0000000..f95ecc7 --- /dev/null +++ b/frontend/src/components/TrustBadge.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import TrustBadge from "./TrustBadge"; + +describe("TrustBadge", () => { + it("renders without crashing", () => { + const { container } = render( + + ); + expect(container).toBeTruthy(); + }); + + it("displays the icon prop", () => { + render(); + const iconEl = screen.getByTestId("trust-badge-icon"); + expect(iconEl.textContent).toBe("🏠"); + }); + + it("displays the stat prop with gold color", () => { + render(); + const statEl = screen.getByTestId("trust-badge-stat"); + expect(statEl.textContent).toBe("200+"); + expect(statEl.style.color).toBe("rgb(200, 169, 81)"); + }); + + it("displays the label prop", () => { + render(); + const labelEl = screen.getByTestId("trust-badge-label"); + expect(labelEl.textContent).toBe("Homes Sold"); + }); + + it("renders with different props for Licensed Agent", () => { + render(); + expect(screen.getByTestId("trust-badge-icon").textContent).toBe("📋"); + expect(screen.getByTestId("trust-badge-stat").textContent).toBe("✓"); + expect(screen.getByTestId("trust-badge-label").textContent).toBe("Licensed Agent"); + }); + + it("renders with different props for 5★ Rated", () => { + render(); + expect(screen.getByTestId("trust-badge-icon").textContent).toBe("⭐"); + expect(screen.getByTestId("trust-badge-stat").textContent).toBe("5★"); + expect(screen.getByTestId("trust-badge-label").textContent).toBe("Rated"); + }); + + it("applies additional className when provided", () => { + render( + + ); + const badge = screen.getByTestId("trust-badge"); + expect(badge.className).toContain("p-4"); + expect(badge.className).toContain("bg-white"); + }); + + it("has the correct aria-label on the icon for accessibility", () => { + render(); + const iconEl = screen.getByTestId("trust-badge-icon"); + expect(iconEl.getAttribute("aria-label")).toBe("Homes Sold"); + }); + + it("has role=img on the icon element", () => { + render(); + const iconEl = screen.getByTestId("trust-badge-icon"); + expect(iconEl.getAttribute("role")).toBe("img"); + }); + + it("does not have trailing spaces in className when no extra class provided", () => { + render(); + const badge = screen.getByTestId("trust-badge"); + expect(badge.className).not.toMatch(/\s$/); + }); +}); diff --git a/frontend/src/components/TrustBadge.tsx b/frontend/src/components/TrustBadge.tsx new file mode 100644 index 0000000..1137b91 --- /dev/null +++ b/frontend/src/components/TrustBadge.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +export interface TrustBadgeProps { + /** Icon or emoji string displayed at the top of the badge */ + icon: string; + /** Bold stat number, e.g. '200+', '5★' */ + stat: string; + /** Descriptive label beneath the stat, e.g. 'Homes Sold' */ + label: string; + /** Optional additional CSS classes for the outer container */ + className?: string; +} + +const TrustBadge: React.FC = ({ + icon, + stat, + label, + className = "", +}) => { + return ( +
+ + {icon} + + + {stat} + + + {label} + +
+ ); +}; + +export default TrustBadge; From 9addea59a6b7e574e697e002c93b6799edc29de3 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:18:17 +0000 Subject: [PATCH 05/21] feat: SectionWrapper component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 0c4b1d21-79e2-4686-9f61-d642db725734 Agent: builder --- .../src/components/SectionWrapper.test.tsx | 132 ++++++++++++++++++ frontend/src/components/SectionWrapper.tsx | 52 +++++++ 2 files changed, 184 insertions(+) create mode 100644 frontend/src/components/SectionWrapper.test.tsx create mode 100644 frontend/src/components/SectionWrapper.tsx diff --git a/frontend/src/components/SectionWrapper.test.tsx b/frontend/src/components/SectionWrapper.test.tsx new file mode 100644 index 0000000..64abad3 --- /dev/null +++ b/frontend/src/components/SectionWrapper.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SectionWrapper from './SectionWrapper'; + +describe('SectionWrapper', () => { + it('renders without crashing', () => { + const { container } = render( + +

Hello

+
+ ); + expect(container.querySelector('section')).toBeTruthy(); + }); + + it('renders children correctly', () => { + render( + +

Welcome

+
+ ); + expect(screen.getByText('Welcome')).toBeTruthy(); + }); + + it('sets the id attribute for scroll targeting', () => { + const { container } = render( + +

About content

+
+ ); + const section = container.querySelector('section'); + expect(section?.getAttribute('id')).toBe('about'); + }); + + it('applies consistent padding classes', () => { + const { container } = render( + +

Padded

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('py-16'); + expect(section?.className).toContain('md:py-24'); + }); + + it('renders the inner container with max-width and horizontal padding', () => { + const { container } = render( + +

Contained

+
+ ); + const innerDiv = container.querySelector('section > div'); + expect(innerDiv?.className).toContain('max-w-7xl'); + expect(innerDiv?.className).toContain('mx-auto'); + expect(innerDiv?.className).toContain('px-4'); + }); + + it('defaults to white background when no bgColor is provided', () => { + const { container } = render( + +

Default

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('bg-white'); + }); + + it('applies cream background class', () => { + const { container } = render( + +

Cream

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('bg-[#FFFDF7]'); + }); + + it('applies slate background class with white text', () => { + const { container } = render( + +

Slate

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('bg-[#1E293B]'); + expect(section?.className).toContain('text-white'); + }); + + it('applies white background class explicitly', () => { + const { container } = render( + +

White

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('bg-white'); + }); + + it('merges additional className prop', () => { + const { container } = render( + +

Custom

+
+ ); + const section = container.querySelector('section'); + expect(section?.className).toContain('border-t'); + expect(section?.className).toContain('border-gray-200'); + }); + + it('does not produce double spaces when className is omitted', () => { + const { container } = render( + +

Clean

+
+ ); + const section = container.querySelector('section'); + const classStr = section?.className ?? ''; + expect(classStr).not.toMatch(/ /); + }); + + it('renders multiple children', () => { + render( + +

Title

+

Paragraph one

+

Paragraph two

+
+ ); + expect(screen.getByText('Title')).toBeTruthy(); + expect(screen.getByText('Paragraph one')).toBeTruthy(); + expect(screen.getByText('Paragraph two')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/SectionWrapper.tsx b/frontend/src/components/SectionWrapper.tsx new file mode 100644 index 0000000..195cc46 --- /dev/null +++ b/frontend/src/components/SectionWrapper.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +export interface SectionWrapperProps { + /** HTML id attribute used as a smooth-scroll navigation target */ + id: string; + /** Section content */ + children: React.ReactNode; + /** Optional additional CSS classes applied to the outer
element */ + className?: string; + /** Background color preset */ + bgColor?: 'cream' | 'white' | 'slate'; +} + +const bgColorMap: Record, string> = { + cream: 'bg-[#FFFDF7]', + white: 'bg-white', + slate: 'bg-[#1E293B] text-white', +}; + +/** + * SectionWrapper provides consistent vertical padding, a centered + * max-width container, an optional background color, and an `id` for + * smooth-scroll anchor targeting. + * + * Usage: + * ```tsx + * + *

About

+ *
+ * ``` + */ +const SectionWrapper: React.FC = ({ + id, + children, + className = '', + bgColor = 'white', +}) => { + const bgClass = bgColorMap[bgColor]; + + return ( +
+
+ {children} +
+
+ ); +}; + +export default SectionWrapper; From 0305dd282897e3f391827f7ef5c4df5825ed56d7 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:18:39 +0000 Subject: [PATCH 06/21] feat: SocialIcons component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 6ed03ca5-0250-4f06-bc8f-25f245e201e9 Agent: builder --- frontend/src/components/SocialIcons.test.tsx | 83 ++++++++++++++++++++ frontend/src/components/SocialIcons.tsx | 80 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 frontend/src/components/SocialIcons.test.tsx create mode 100644 frontend/src/components/SocialIcons.tsx diff --git a/frontend/src/components/SocialIcons.test.tsx b/frontend/src/components/SocialIcons.test.tsx new file mode 100644 index 0000000..5112f2b --- /dev/null +++ b/frontend/src/components/SocialIcons.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SocialIcons from './SocialIcons'; + +describe('SocialIcons', () => { + it('renders without crashing', () => { + render(); + const container = screen.getByTestId('social-icons'); + expect(container).toBeTruthy(); + }); + + it('renders both Instagram and LinkedIn icons', () => { + render(); + const instagram = screen.getByTestId('social-icon-instagram'); + const linkedin = screen.getByTestId('social-icon-linkedin'); + expect(instagram).toBeTruthy(); + expect(linkedin).toBeTruthy(); + }); + + it('each icon link has correct target and rel attributes', () => { + render(); + const instagram = screen.getByTestId('social-icon-instagram'); + const linkedin = screen.getByTestId('social-icon-linkedin'); + + expect(instagram.getAttribute('target')).toBe('_blank'); + expect(instagram.getAttribute('rel')).toBe('noopener noreferrer'); + expect(linkedin.getAttribute('target')).toBe('_blank'); + expect(linkedin.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('each icon link has href="#"', () => { + render(); + const instagram = screen.getByTestId('social-icon-instagram'); + const linkedin = screen.getByTestId('social-icon-linkedin'); + + expect(instagram.getAttribute('href')).toBe('#'); + expect(linkedin.getAttribute('href')).toBe('#'); + }); + + it('renders accessible aria-labels on links', () => { + render(); + const instagram = screen.getByLabelText('Instagram'); + const linkedin = screen.getByLabelText('LinkedIn'); + expect(instagram).toBeTruthy(); + expect(linkedin).toBeTruthy(); + }); + + it('contains SVG elements inside each link', () => { + render(); + const instagram = screen.getByTestId('social-icon-instagram'); + const linkedin = screen.getByTestId('social-icon-linkedin'); + + expect(instagram.querySelector('svg')).not.toBeNull(); + expect(linkedin.querySelector('svg')).not.toBeNull(); + }); + + it('applies custom className to the container', () => { + render(); + const container = screen.getByTestId('social-icons'); + expect(container.className).toContain('mt-8'); + expect(container.className).toContain('justify-center'); + }); + + it('renders with default classes when no className is provided', () => { + render(); + const container = screen.getByTestId('social-icons'); + expect(container.className).toContain('inline-flex'); + expect(container.className).toContain('items-center'); + expect(container.className).toContain('gap-4'); + }); + + it('SVG icons have aria-hidden="true"', () => { + render(); + const instagram = screen.getByTestId('social-icon-instagram'); + const linkedin = screen.getByTestId('social-icon-linkedin'); + + const instaSvg = instagram.querySelector('svg'); + const linkedSvg = linkedin.querySelector('svg'); + + expect(instaSvg?.getAttribute('aria-hidden')).toBe('true'); + expect(linkedSvg?.getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/frontend/src/components/SocialIcons.tsx b/frontend/src/components/SocialIcons.tsx new file mode 100644 index 0000000..8634b81 --- /dev/null +++ b/frontend/src/components/SocialIcons.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +export interface SocialIconsProps { + /** Additional CSS class names for layout customization (e.g., flex direction, gap overrides) */ + className?: string; +} + +/** + * SocialIcons renders inline SVG icons for Instagram and LinkedIn. + * Each icon is wrapped in an anchor tag and transitions to gold on hover. + */ +const SocialIcons: React.FC = ({ className = '' }) => { + const iconStyle: React.CSSProperties = { + width: 24, + height: 24, + display: 'block', + }; + + const linkBaseClasses = + 'inline-flex items-center justify-center text-slate-600 transition-colors duration-300 hover:text-[#C8A951] focus:text-[#C8A951] focus:outline-none'; + + return ( +
+ {/* Instagram */} + + + + + {/* LinkedIn */} + + + +
+ ); +}; + +export default SocialIcons; From 51407d4b5f407a6b979ff5ab77b767c25c7fadfc Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:18:41 +0000 Subject: [PATCH 07/21] feat: PropertyCard component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 8ef5f26d-bc15-4e76-8f71-807f8da80808 Agent: builder --- frontend/src/components/PropertyCard.test.tsx | 96 +++++++++++++++++++ frontend/src/components/PropertyCard.tsx | 74 ++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 frontend/src/components/PropertyCard.test.tsx create mode 100644 frontend/src/components/PropertyCard.tsx diff --git a/frontend/src/components/PropertyCard.test.tsx b/frontend/src/components/PropertyCard.test.tsx new file mode 100644 index 0000000..3306749 --- /dev/null +++ b/frontend/src/components/PropertyCard.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import PropertyCard, { PropertyCardProps } from './PropertyCard'; + +const defaultProps: PropertyCardProps = { + imageUrl: 'https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=800', + address: '123 Maple Street, Beverly Hills, CA 90210', + price: '$1.2M', + status: 'SOLD', +}; + +describe('PropertyCard', () => { + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders the property card container', () => { + render(); + const card = screen.getByTestId('property-card'); + expect(card).toBeDefined(); + }); + + it('displays the property image with correct src and alt', () => { + render(); + const img = screen.getByRole('img'); + expect(img.getAttribute('src')).toBe(defaultProps.imageUrl); + expect(img.getAttribute('alt')).toBe(defaultProps.address); + }); + + it('uses custom imageAlt when provided', () => { + render(); + const img = screen.getByRole('img'); + expect(img.getAttribute('alt')).toBe('Beautiful home'); + }); + + it('displays the SOLD status badge', () => { + render(); + const badge = screen.getByTestId('status-badge'); + expect(badge.textContent).toBe('SOLD'); + }); + + it('styles the badge with dark background and gold text', () => { + render(); + const badge = screen.getByTestId('status-badge'); + expect(badge.style.backgroundColor).toBe('rgb(30, 41, 59)'); + expect(badge.style.color).toBe('rgb(200, 169, 81)'); + }); + + it('displays the address', () => { + render(); + const address = screen.getByTestId('property-address'); + expect(address.textContent).toBe(defaultProps.address); + }); + + it('displays the price in bold', () => { + render(); + const price = screen.getByTestId('property-price'); + expect(price.textContent).toBe(defaultProps.price); + expect(price.className).toContain('font-bold'); + }); + + it('renders with different prop values', () => { + const customProps: PropertyCardProps = { + imageUrl: 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800', + address: '456 Oak Avenue, Malibu, CA 90265', + price: '$3.5M', + status: 'SOLD', + }; + render(); + expect(screen.getByTestId('property-address').textContent).toBe(customProps.address); + expect(screen.getByTestId('property-price').textContent).toBe(customProps.price); + expect(screen.getByRole('img').getAttribute('src')).toBe(customProps.imageUrl); + }); + + it('has hover-related transition classes on the card', () => { + render(); + const card = screen.getByTestId('property-card'); + expect(card.className).toContain('hover:shadow-xl'); + expect(card.className).toContain('hover:scale-'); + expect(card.className).toContain('transition-all'); + }); + + it('has rounded overflow-hidden styling', () => { + render(); + const card = screen.getByTestId('property-card'); + expect(card.className).toContain('rounded-xl'); + expect(card.className).toContain('overflow-hidden'); + }); + + it('sets lazy loading on the image', () => { + render(); + const img = screen.getByRole('img'); + expect(img.getAttribute('loading')).toBe('lazy'); + }); +}); diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx new file mode 100644 index 0000000..08aa386 --- /dev/null +++ b/frontend/src/components/PropertyCard.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +export interface PropertyCardProps { + /** Unsplash or any valid image URL */ + imageUrl: string; + /** Street address of the property */ + address: string; + /** Formatted price string, e.g. '$1.2M' */ + price: string; + /** Sale status displayed as an overlay badge */ + status: 'SOLD'; + /** Alt text for the image; defaults to address if omitted */ + imageAlt?: string; +} + +const PropertyCard: React.FC = ({ + imageUrl, + address, + price, + status, + imageAlt, +}) => { + return ( +
+ {/* Image Container */} +
+ {imageAlt + + {/* Status Badge Overlay */} + + {status} + + + {/* Gradient overlay at bottom of image for readability */} +
+
+ + {/* Content */} +
+

+ {address} +

+

+ {price} +

+
+
+ ); +}; + +export default PropertyCard; From 8856d8f132619c5359ea3038bf117f434036d36c Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:18:45 +0000 Subject: [PATCH 08/21] feat: Global styles, main entry point, and index.html Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 5c3eee91-0a2b-4710-9e23-f64c29c3e131 Agent: builder --- ARCHITECTURE.md | 207 ++++++++++++++++------------------ SETUP.md | 42 ++++--- package.json | 27 +++-- src/App.tsx | 126 ++------------------- src/index.css | 39 +++++++ tailwind.config.js | 55 +++------ tests/setup.ts | 5 + tests/test_index_css.test.ts | 87 ++++++++++++++ tests/test_index_html.test.ts | 60 ++++++++++ tests/test_main.test.tsx | 28 +++++ tsconfig.json | 9 +- vite.config.ts | 19 ++++ vitest.config.ts | 22 ++++ 13 files changed, 425 insertions(+), 301 deletions(-) create mode 100644 tests/setup.ts create mode 100644 tests/test_index_css.test.ts create mode 100644 tests/test_index_html.test.ts create mode 100644 tests/test_main.test.tsx create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0e86c5..a391860 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,46 +1,47 @@ -# Maddie Real Estate Landing Page — Architecture Document +# Maddie | Luxury Real Estate — Architecture Document ## Overview -A single-page luxury real estate landing page built with React, Vite, TypeScript, and Tailwind CSS. The page showcases a real estate agent's brand, recent sales, and contact information with a warm, elegant aesthetic. +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 # Root HTML with Google Fonts & meta tags -├── package.json # Dependencies and scripts -├── vite.config.js # Vite config with React plugin & path aliases -├── tailwind.config.js # Tailwind design tokens -├── postcss.config.js # PostCSS plugins (tailwindcss, autoprefixer) -├── tsconfig.json # TypeScript compiler options -├── tsconfig.node.json # TypeScript config for Node (Vite config) -├── ARCHITECTURE.md # This file -├── SETUP.md # Setup instructions +├── 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 application component -│ ├── index.css # Global styles & Tailwind directives -│ ├── vite-env.d.ts # Vite client type declarations +│ ├── main.tsx # React entry point +│ ├── App.tsx # Root component +│ ├── index.css # Global styles & Tailwind directives │ ├── components/ -│ │ ├── Header.tsx # Fixed navigation bar -│ │ ├── Hero.tsx # Full-width hero section -│ │ ├── About.tsx # About / agent profile section -│ │ ├── RecentSales.tsx # Property cards grid -│ │ ├── PropertyCard.tsx # Individual property card -│ │ ├── Contact.tsx # Contact form / CTA section -│ │ ├── Footer.tsx # Site footer -│ │ └── ScrollToTop.tsx # Scroll-to-top utility button -│ ├── hooks/ -│ │ └── useSmoothScroll.ts # Hook for smooth scroll-to-id navigation +│ │ ├── 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 # Utility function for smooth scrolling +│ │ └── scrollTo.ts # Smooth scroll-to-id utility │ └── types/ -│ └── index.ts # Shared TypeScript interfaces +│ └── index.ts # Shared TypeScript interfaces └── tests/ - └── test_config.py # Validation tests for config files + ├── setup.ts # Test setup (jsdom, jest-dom) + ├── test_index_html.test.ts + ├── test_main.test.tsx + └── test_index_css.test.ts ``` --- @@ -49,19 +50,13 @@ A single-page luxury real estate landing page built with React, Vite, TypeScript ``` App -├── Header -│ └── Nav links (smooth scroll anchors) -├── Hero -│ └── CTA Button -├── About -│ └── Profile image + bio text -├── RecentSales -│ └── PropertyCard[] (grid of 3–6 cards) -├── Contact -│ └── Contact form / CTA -├── Footer -│ └── Social links, copyright -└── ScrollToTop +├── 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) ``` --- @@ -70,88 +65,86 @@ App ### Color Palette -| Token | Hex | Usage | -|-------------------|-----------|--------------------------------------| -| cream | `#FDF8F0` | Primary background | -| cream-light | `#FFFDF7` | Card backgrounds, subtle contrast | -| cream-dark | `#F5EDE0` | Borders, dividers | -| slate-900 | `#1E293B` | Primary headings | -| slate-700 | `#334155` | Body text | -| slate-500 | `#64748B` | Secondary text | -| slate-400 | `#94A3B8` | Muted text, placeholders | -| gold | `#C9A84C` | Primary accent | -| gold-light | `#D4B968` | Hover states | -| gold-lighter | `#E8D5A3` | Subtle accent backgrounds | -| gold-dark | `#B8943F` | Active states | -| warm-white | `#FAF7F2` | Alternate section background | - -### Typography - -| Element | Font Family | Weights | Sizes (desktop) | -|----------------|-------------------|------------------|-----------------------| -| h1 | Playfair Display | 700 | 4xl–6xl | -| h2 | Playfair Display | 600, 700 | 3xl–4xl | -| h3 | Playfair Display | 500, 600 | 2xl–3xl | -| h4–h6 | Playfair Display | 500 | xl–2xl | -| body | Inter | 300, 400, 500 | base–lg | -| button | Inter | 500, 600 | sm–base | -| caption | Inter | 400 | sm | - -Fonts are loaded via Google Fonts CDN in `index.html`. - -### Responsive Breakpoints - -| Breakpoint | Min Width | Usage | -|------------|-----------|--------------------------------| -| sm | 640px | Mobile landscape | -| md | 768px | Tablet portrait | -| lg | 1024px | Tablet landscape / small laptop| -| xl | 1280px | Desktop | -| 2xl | 1536px | Large desktop | - -These are Tailwind defaults — no overrides needed. +| 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%); +``` --- -## Section Order +## Typography + +| Role | Font Family | Weights | +| -------- | ----------------- | ------------------ | +| Headings | Playfair Display | 400, 500, 600, 700 | +| Body | Inter | 300, 400, 500, 600, 700 | -1. **Header / Nav** — Fixed top bar with logo and smooth-scroll nav links -2. **Hero** — Full-viewport hero with background image, heading, and CTA -3. **About / Profile** — Agent photo + bio with split layout -4. **Recent Sales** — Grid of property cards with images, price, details -5. **Contact** — Contact form or CTA block -6. **Footer** — Branding, social links, legal +Fonts are loaded via Google Fonts `` in `index.html` with +`preconnect` hints for optimal loading. --- -## Image Strategy +## Responsive Breakpoints -Curated Unsplash images (free to use): +| Name | Min Width | Usage | +| ---- | --------- | -------------------------- | +| sm | 640px | Tablet portrait | +| md | 768px | Tablet landscape | +| lg | 1024px | Desktop | +| xl | 1280px | Large desktop | -- **Hero**: `https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1920&q=80` (luxury home exterior) -- **About**: `https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=800&q=80` (professional portrait) -- **Property 1**: `https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600&q=80` -- **Property 2**: `https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=600&q=80` -- **Property 3**: `https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=600&q=80` +--- + +## 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. `html { scroll-behavior: smooth; }` in `index.html` and `src/index.css` -2. `scrollTo.ts` utility: `document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })` -3. `useSmoothScroll` hook wraps the utility for use in nav links -4. Each section has an `id` attribute matching the nav link href +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 the full configuration. Key extensions: +See `tailwind.config.js` for full configuration. Key extensions: -- **colors**: cream, gold, warm-white palettes added alongside Tailwind slate -- **fontFamily**: `playfair` → `['Playfair Display', 'serif']`, `inter` → `['Inter', 'sans-serif']` -- **spacing**: `18` (4.5rem), `88` (22rem), `128` (32rem) for hero/section sizing -- **maxWidth**: `8xl` (88rem) for wide section containers -- **animation**: `fade-in`, `slide-up` for entrance animations -- **keyframes**: Custom keyframes for the above animations +- 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/SETUP.md b/SETUP.md index 6696427..0b00989 100644 --- a/SETUP.md +++ b/SETUP.md @@ -2,46 +2,42 @@ ## Prerequisites -- Node.js >= 18.x -- npm >= 9.x (or pnpm / yarn) +- Node.js >= 18 +- npm >= 9 -## Installation +## Install Dependencies ```bash -# Navigate to project root -cd . - -# Install dependencies npm install ``` -## Development +This will generate `package-lock.json` and `node_modules/` — both are +git-ignored and must not be hand-written. + +## Development Server ```bash -# Start the Vite dev server on port 3000 npm run dev ``` -## Build +Opens the dev server at `http://localhost:3000`. -```bash -# Create production build in dist/ -npm run build +## Run Tests -# Preview production build locally -npm run preview +```bash +npm test ``` -## Testing +## Production Build ```bash -# Run config validation tests (requires Python 3.8+ with pytest) -pip install pytest -pytest tests/ +npm run build ``` -## Notes +Output is written to `dist/`. + +## Preview Production Build -- Do NOT commit `node_modules/`, `dist/`, or lock files (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`). -- Lock files are generated automatically by your package manager during `npm install`. -- The `tsconfig.json` and `tsconfig.node.json` files are hand-written and should be committed. +```bash +npm run preview +``` diff --git a/package.json b/package.json index 150bfbd..bee9afc 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,30 @@ "private": true, "version": "1.0.0", "type": "module", - "description": "Luxury real estate landing page built with React, Vite, and Tailwind CSS", + "description": "Maddie — Luxury real estate agent landing page", "scripts": { - "dev": "vite --port 3000", + "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "tsc --noEmit" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.4", - "typescript": "^5.5.3", - "vite": "^5.3.4" + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vitest": "^2.1.8" } } diff --git a/src/App.tsx b/src/App.tsx index 3754781..4dc31af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,126 +1,22 @@ +import React from 'react'; + /** * Root application component. - * - * Composes all page sections in the correct order: - * Header → Hero → About → Recent Sales → Contact → Footer + * Serves as the top-level layout wrapper for all page sections. */ -function App(): JSX.Element { +const App: React.FC = () => { return (
- {/* Header */} - - - {/* Hero Section */} -
-
-
-

- Your Dream Home Awaits -

-

- Specializing in luxury properties with a personal touch. - Let's find the perfect place for you. -

- -
-
-
- - {/* About Section */} -
-
-

About Maddie

-

- With over a decade of experience in luxury real estate, I help - families find their perfect home. My commitment to personalized - service sets me apart. -

-
-
- - {/* Recent Sales Section */} -
-
-

Recent Sales

-

- A selection of recently sold properties. -

-
- {/* Property cards will be added in subsequent phases */} -
-
-

Coming Soon

-

- Property listings will appear here. -

-
-
-
-
- - {/* Contact Section */} -
-
-

Let's Connect

-

- Ready to start your journey? Reach out today and let's make - your real estate dreams a reality. -

- -
-
- - {/* Footer */} -
-
-

- Maddie -

-

- © {new Date().getFullYear()} Maddie Real Estate. All rights - reserved. +

+
+

Maddie | Luxury Real Estate

+

+ Welcome to a new standard of luxury living.

-
+
); -} +}; export default App; diff --git a/src/index.css b/src/index.css index fa802d5..c37290d 100644 --- a/src/index.css +++ b/src/index.css @@ -60,6 +60,45 @@ focus:ring-2 focus:ring-gold focus:ring-offset-2 focus:ring-offset-cream; } + + .gold-gradient { + background: linear-gradient(135deg, #C8A951 0%, #E8D5A3 50%, #D4B968 100%); + } + + .gold-gradient-text { + background: linear-gradient(135deg, #C8A951 0%, #E8D5A3 50%, #D4B968 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .gold-gradient-border { + border-image: linear-gradient(135deg, #C8A951 0%, #E8D5A3 50%, #D4B968 100%) 1; + } + + .hover-lift { + @apply transition-transform duration-300 ease-out; + } + + .hover-lift:hover { + @apply -translate-y-1; + } + + .hover-scale { + @apply transition-transform duration-300 ease-out; + } + + .hover-scale:hover { + @apply scale-105; + } + + .hover-glow { + @apply transition-shadow duration-300 ease-out; + } + + .hover-glow:hover { + box-shadow: 0 0 20px rgba(200, 169, 81, 0.3); + } } @layer utilities { diff --git a/tailwind.config.js b/tailwind.config.js index 937e28c..4c583de 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,54 +8,35 @@ export default { extend: { colors: { cream: { - DEFAULT: '#FDF8F0', + DEFAULT: '#FFFDF7', light: '#FFFDF7', - dark: '#F5EDE0', + dark: '#FFF8F0', }, - gold: { - DEFAULT: '#C9A84C', - light: '#D4B968', - lighter: '#E8D5A3', - dark: '#B8943F', - }, - 'warm-white': '#FAF7F2', slate: { - 400: '#94A3B8', - 500: '#64748B', 700: '#334155', - 900: '#1E293B', + 800: '#1E293B', + 900: '#0F172A', + 500: '#64748B', + 400: '#94A3B8', + }, + gold: { + light: '#E8D5A3', + DEFAULT: '#C8A951', + dark: '#B8963E', + medium: '#D4B968', }, }, fontFamily: { - playfair: ['Playfair Display', 'serif'], - inter: ['Inter', 'sans-serif'], - }, - spacing: { - 18: '4.5rem', - 88: '22rem', - 128: '32rem', + playfair: ['Playfair Display', 'Georgia', 'serif'], + inter: ['Inter', 'system-ui', 'sans-serif'], }, maxWidth: { '8xl': '88rem', }, - keyframes: { - 'fade-in': { - '0%': { opacity: '0' }, - '100%': { opacity: '1' }, - }, - 'slide-up': { - '0%': { opacity: '0', transform: 'translateY(20px)' }, - '100%': { opacity: '1', transform: 'translateY(0)' }, - }, - 'slide-down': { - '0%': { opacity: '0', transform: 'translateY(-20px)' }, - '100%': { opacity: '1', transform: 'translateY(0)' }, - }, - }, - animation: { - 'fade-in': 'fade-in 0.6s ease-out forwards', - 'slide-up': 'slide-up 0.6s ease-out forwards', - 'slide-down': 'slide-down 0.6s ease-out forwards', + spacing: { + '18': '4.5rem', + '88': '22rem', + '128': '32rem', }, }, }, diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..945ec94 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,5 @@ +/** + * Test setup file for Vitest. + * Configures jsdom environment and extends matchers with jest-dom. + */ +import '@testing-library/jest-dom'; diff --git a/tests/test_index_css.test.ts b/tests/test_index_css.test.ts new file mode 100644 index 0000000..53826ca --- /dev/null +++ b/tests/test_index_css.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for src/index.css content. + * Validates that the global stylesheet contains required Tailwind directives, + * base styles, component classes, and custom utilities. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +let cssContent: string; + +beforeAll(() => { + cssContent = readFileSync( + resolve(__dirname, '..', 'src', 'index.css'), + 'utf-8', + ); +}); + +describe('src/index.css', () => { + it('should include @tailwind base directive', () => { + expect(cssContent).toContain('@tailwind base;'); + }); + + it('should include @tailwind components directive', () => { + expect(cssContent).toContain('@tailwind components;'); + }); + + it('should include @tailwind utilities directive', () => { + expect(cssContent).toContain('@tailwind utilities;'); + }); + + it('should set smooth scroll on html', () => { + expect(cssContent).toContain('scroll-behavior: smooth'); + }); + + it('should set body font to Inter via font-inter', () => { + expect(cssContent).toContain('font-inter'); + }); + + it('should set heading font to Playfair Display via font-playfair', () => { + expect(cssContent).toContain('font-playfair'); + }); + + it('should define section-container component class', () => { + expect(cssContent).toContain('.section-container'); + }); + + it('should define section-padding component class', () => { + expect(cssContent).toContain('.section-padding'); + }); + + it('should define btn-primary component class', () => { + expect(cssContent).toContain('.btn-primary'); + }); + + it('should define btn-secondary component class', () => { + expect(cssContent).toContain('.btn-secondary'); + }); + + it('should define gold-gradient component class', () => { + expect(cssContent).toContain('.gold-gradient'); + }); + + it('should define gold-gradient-text component class', () => { + expect(cssContent).toContain('.gold-gradient-text'); + }); + + it('should define hover-lift animation class', () => { + expect(cssContent).toContain('.hover-lift'); + }); + + it('should define hover-scale animation class', () => { + expect(cssContent).toContain('.hover-scale'); + }); + + it('should define hover-glow animation class', () => { + expect(cssContent).toContain('.hover-glow'); + }); + + it('should include webkit font smoothing', () => { + expect(cssContent).toContain('-webkit-font-smoothing: antialiased'); + }); + + it('should set cream background on body', () => { + expect(cssContent).toContain('bg-cream'); + }); +}); diff --git a/tests/test_index_html.test.ts b/tests/test_index_html.test.ts new file mode 100644 index 0000000..af448c4 --- /dev/null +++ b/tests/test_index_html.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for index.html structure and SEO attributes. + * Validates that the HTML entry point contains required meta tags, + * Google Fonts links, and the root mount point. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +let htmlContent: string; + +beforeAll(() => { + htmlContent = readFileSync(resolve(__dirname, '..', 'index.html'), 'utf-8'); +}); + +describe('index.html', () => { + it('should have the correct title', () => { + expect(htmlContent).toContain('Maddie | Luxury Real Estate'); + }); + + it('should have a meta description', () => { + expect(htmlContent).toContain('name="description"'); + expect(htmlContent).toContain('Luxury real estate'); + }); + + it('should have a viewport meta tag', () => { + expect(htmlContent).toContain('name="viewport"'); + expect(htmlContent).toContain('width=device-width'); + }); + + it('should preconnect to Google Fonts', () => { + expect(htmlContent).toContain('href="https://fonts.googleapis.com"'); + expect(htmlContent).toContain('href="https://fonts.gstatic.com"'); + }); + + it('should load Playfair Display font', () => { + expect(htmlContent).toContain('Playfair+Display'); + }); + + it('should load Inter font', () => { + expect(htmlContent).toContain('Inter'); + }); + + it('should have smooth scroll on html element', () => { + expect(htmlContent).toContain('scroll-behavior: smooth'); + }); + + it('should have a root div mount point', () => { + expect(htmlContent).toContain('
'); + }); + + it('should reference main.tsx as module script', () => { + expect(htmlContent).toContain('src="/src/main.tsx"'); + expect(htmlContent).toContain('type="module"'); + }); + + it('should have lang="en" attribute', () => { + expect(htmlContent).toContain('lang="en"'); + }); +}); diff --git a/tests/test_main.test.tsx b/tests/test_main.test.tsx new file mode 100644 index 0000000..aa42f27 --- /dev/null +++ b/tests/test_main.test.tsx @@ -0,0 +1,28 @@ +/** + * Tests for the main entry point and App component rendering. + * Verifies that the App component mounts correctly into the DOM. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import App from '../src/App'; + +describe('App component', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render the heading', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent('Maddie | Luxury Real Estate'); + }); + + it('should have a main element', () => { + render(); + const main = screen.getByRole('main'); + expect(main).toBeInTheDocument(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 131fb44..59f5b5d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,27 +5,20 @@ "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": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - - /* Path aliases */ "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src", "tests"] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7abfbd4 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +/** + * Vite configuration for the Maddie Luxury Real Estate landing page. + * Configures React plugin, path aliases, and dev server. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7c9930c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +/** + * Vitest configuration. + * Sets up jsdom environment and test file patterns. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.{ts,tsx}'], + globals: true, + }, +}); From 415150b15369a9bf8819600d8453b241e328b6f7 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:19:14 +0000 Subject: [PATCH 09/21] feat: Hero section component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 6486fd99-f19a-428b-ba28-b78324ffc174 Agent: builder --- frontend/src/components/Hero.test.tsx | 91 ++++++++++++++++++++ frontend/src/components/Hero.tsx | 116 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 frontend/src/components/Hero.test.tsx create mode 100644 frontend/src/components/Hero.tsx diff --git a/frontend/src/components/Hero.test.tsx b/frontend/src/components/Hero.test.tsx new file mode 100644 index 0000000..31a3608 --- /dev/null +++ b/frontend/src/components/Hero.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Hero, { HeroProps } from './Hero'; + +const defaultProps: HeroProps = { + headline: 'Your Dream Home Starts Here', + subheading: 'Luxury real estate tailored to your lifestyle.', + backgroundImageUrl: + 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9', + primaryCtaLabel: 'View My Listings', + primaryCtaHref: '#listings', + secondaryCtaLabel: 'Get In Touch', + secondaryCtaHref: '#contact', +}; + +describe('Hero', () => { + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('displays the headline text', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading.textContent).toBe('Your Dream Home Starts Here'); + }); + + it('displays the subheading text', () => { + render(); + expect(screen.getByText('Luxury real estate tailored to your lifestyle.')).toBeTruthy(); + }); + + it('renders the primary CTA with correct href', () => { + render(); + const primaryLink = screen.getByText('View My Listings'); + expect(primaryLink.tagName).toBe('A'); + expect(primaryLink.getAttribute('href')).toBe('#listings'); + }); + + it('renders the secondary CTA with correct href', () => { + render(); + const secondaryLink = screen.getByText('Get In Touch'); + expect(secondaryLink.tagName).toBe('A'); + expect(secondaryLink.getAttribute('href')).toBe('#contact'); + }); + + it('applies the background image URL as inline style', () => { + render(); + const section = screen.getByRole('banner'); + expect(section.style.backgroundImage).toContain( + 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9' + ); + }); + + it('uses the default aria-label when backgroundAlt is not provided', () => { + render(); + const section = screen.getByRole('banner'); + expect(section.getAttribute('aria-label')).toBe('Luxury home exterior'); + }); + + it('uses a custom backgroundAlt when provided', () => { + render(); + const section = screen.getByRole('banner'); + expect(section.getAttribute('aria-label')).toBe('Beautiful mansion'); + }); + + it('renders the overlay div for text readability', () => { + const { container } = render(); + const overlay = container.querySelector('[aria-hidden="true"]'); + expect(overlay).toBeTruthy(); + expect(overlay!.getAttribute('style')).toContain('linear-gradient'); + }); + + it('renders with custom headline and subheading props', () => { + render( + + ); + expect(screen.getByText('Find Your Paradise')).toBeTruthy(); + expect(screen.getByText('We make it happen.')).toBeTruthy(); + }); + + it('renders both CTA buttons as links', () => { + render(); + const links = screen.getAllByRole('link'); + expect(links.length).toBe(2); + }); +}); diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx new file mode 100644 index 0000000..1ad4a5c --- /dev/null +++ b/frontend/src/components/Hero.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +export interface HeroProps { + /** Main headline text */ + headline: string; + /** Subheading / supporting text */ + subheading: string; + /** Background image URL */ + backgroundImageUrl: string; + /** Primary CTA button label */ + primaryCtaLabel: string; + /** Primary CTA href (e.g. "#listings") */ + primaryCtaHref: string; + /** Secondary CTA button label */ + secondaryCtaLabel: string; + /** Secondary CTA href (e.g. "#contact") */ + secondaryCtaHref: string; + /** Optional alt text for the background (used as aria-label) */ + backgroundAlt?: string; +} + +const Hero: React.FC = ({ + headline, + subheading, + backgroundImageUrl, + primaryCtaLabel, + primaryCtaHref, + secondaryCtaLabel, + secondaryCtaHref, + backgroundAlt = 'Luxury home exterior', +}) => { + return ( +
+ {/* Dark overlay gradient */} +
+ ); +}; + +export default Hero; From 2da163458fae37ff8b60cec53f8a5d7360771f30 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:19:15 +0000 Subject: [PATCH 10/21] feat: Header / Navbar component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: b2616820-a08e-44a2-b48a-d2dddd69fafe Agent: builder --- frontend/src/components/Header.test.tsx | 140 +++++++++++++++++++++++ frontend/src/components/Header.tsx | 146 ++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 frontend/src/components/Header.test.tsx create mode 100644 frontend/src/components/Header.tsx diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx new file mode 100644 index 0000000..f13163f --- /dev/null +++ b/frontend/src/components/Header.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Header, { HeaderProps } from './Header'; + +const defaultProps: HeaderProps = { + logoText: 'Maddie Lane', + navLinks: [ + { label: 'About', href: '#about' }, + { label: 'Listings', href: '#listings' }, + { label: 'Contact', href: '#contact' }, + ], + ctaText: 'Contact Maddie', + ctaHref: '#contact', +}; + +describe('Header', () => { + it('renders without crashing', () => { + render(
); + expect(screen.getByTestId('header')).toBeInTheDocument(); + }); + + it('displays the logo text from props', () => { + render(
); + expect(screen.getByTestId('header-logo')).toHaveTextContent('Maddie Lane'); + }); + + it('renders all nav links in desktop nav', () => { + render(
); + const desktopNav = screen.getByTestId('desktop-nav'); + expect(desktopNav.querySelectorAll('a')).toHaveLength(3); + expect(desktopNav).toHaveTextContent('About'); + expect(desktopNav).toHaveTextContent('Listings'); + expect(desktopNav).toHaveTextContent('Contact'); + }); + + it('renders nav links with correct href attributes', () => { + render(
); + const desktopNav = screen.getByTestId('desktop-nav'); + const links = desktopNav.querySelectorAll('a'); + expect(links[0]).toHaveAttribute('href', '#about'); + expect(links[1]).toHaveAttribute('href', '#listings'); + expect(links[2]).toHaveAttribute('href', '#contact'); + }); + + it('renders the CTA button with correct text and href', () => { + render(
); + const cta = screen.getByTestId('header-cta'); + expect(cta).toHaveTextContent('Contact Maddie'); + expect(cta).toHaveAttribute('href', '#contact'); + }); + + it('mobile menu is collapsed by default', () => { + render(
); + const mobileMenu = screen.getByTestId('mobile-menu'); + expect(mobileMenu).toHaveClass('max-h-0'); + expect(mobileMenu).toHaveClass('opacity-0'); + }); + + it('toggles mobile menu open and closed on hamburger click', () => { + render(
); + const button = screen.getByTestId('mobile-menu-button'); + const mobileMenu = screen.getByTestId('mobile-menu'); + + // Open + fireEvent.click(button); + expect(mobileMenu).toHaveClass('max-h-96'); + expect(mobileMenu).toHaveClass('opacity-100'); + expect(button).toHaveAttribute('aria-expanded', 'true'); + + // Close + fireEvent.click(button); + expect(mobileMenu).toHaveClass('max-h-0'); + expect(mobileMenu).toHaveClass('opacity-0'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('closes mobile menu when a nav link is clicked', () => { + render(
); + const button = screen.getByTestId('mobile-menu-button'); + const mobileMenu = screen.getByTestId('mobile-menu'); + + fireEvent.click(button); + expect(mobileMenu).toHaveClass('max-h-96'); + + const mobileLinks = mobileMenu.querySelectorAll('a'); + fireEvent.click(mobileLinks[0]); + expect(mobileMenu).toHaveClass('max-h-0'); + }); + + it('closes mobile menu when mobile CTA is clicked', () => { + render(
); + const button = screen.getByTestId('mobile-menu-button'); + const mobileMenu = screen.getByTestId('mobile-menu'); + + fireEvent.click(button); + const mobileCta = screen.getByTestId('mobile-cta'); + fireEvent.click(mobileCta); + expect(mobileMenu).toHaveClass('max-h-0'); + }); + + it('applies shadow class when scrolled', () => { + render(
); + const header = screen.getByTestId('header'); + + expect(header).toHaveClass('shadow-none'); + + // Simulate scroll + Object.defineProperty(window, 'scrollY', { value: 50, writable: true }); + fireEvent.scroll(window); + + expect(header).toHaveClass('shadow-md'); + }); + + it('does not hardcode logo text - uses props', () => { + render(
); + expect(screen.getByTestId('header-logo')).toHaveTextContent('Custom Logo'); + }); + + it('does not hardcode CTA text - uses props', () => { + render(
); + const cta = screen.getByTestId('header-cta'); + expect(cta).toHaveTextContent('Get in Touch'); + expect(cta).toHaveAttribute('href', '#get-in-touch'); + }); + + it('renders with empty nav links array', () => { + render(
); + const desktopNav = screen.getByTestId('desktop-nav'); + expect(desktopNav.querySelectorAll('a')).toHaveLength(0); + }); + + it('hamburger button has correct aria-label', () => { + render(
); + const button = screen.getByTestId('mobile-menu-button'); + expect(button).toHaveAttribute('aria-label', 'Open menu'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-label', 'Close menu'); + }); +}); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..6a074c1 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; + +export interface NavLink { + label: string; + href: string; +} + +export interface HeaderProps { + logoText: string; + navLinks: NavLink[]; + ctaText: string; + ctaHref: string; +} + +const Header: React.FC = ({ logoText, navLinks, ctaText, ctaHref }) => { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setScrolled(window.scrollY > 10); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const toggleMobileMenu = () => { + setMobileMenuOpen((prev) => !prev); + }; + + const handleLinkClick = () => { + setMobileMenuOpen(false); + }; + + return ( +
+ + + {/* Mobile Slide-Down Menu */} +
+
+ + + {ctaText} + +
+
+
+ ); +}; + +export default Header; From 687111317dc0d4eaee33e3ff11af7209653e2d18 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 01:19:26 +0000 Subject: [PATCH 11/21] feat: ContactForm component Run: 0c8be8bf-c44a-41c1-98ea-fde222ad76df Task: 52292ff1-88aa-4142-8f16-487f0b9e5db6 Agent: builder --- frontend/src/components/ContactForm.test.tsx | 169 +++++++++++++++++++ frontend/src/components/ContactForm.tsx | 145 ++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 frontend/src/components/ContactForm.test.tsx create mode 100644 frontend/src/components/ContactForm.tsx 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}

} +
+ +
+ +