diff --git a/src/app/page.tsx b/src/app/page.tsx index 0f835576..ab397116 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -68,9 +68,17 @@ function HomeContent() { Multi-Chain Real Estate Platform

- Experience seamless wallet connectivity across Ethereum, Polygon, - and Binance Smart Chain + Experience seamless wallet connectivity across Ethereum, Polygon, and Binance Smart Chain

+ + + + + Browse Properties + setStoreViewMode(mode); + + const { + filters, + sortBy, + page, + properties, + totalResults, + totalPages, + isLoading, + error, + setFilter, + clearFilters, + setSortBy, + setPage, + } = usePropertySearch(); + + return ( +
+ {/* Header */} +
+
+
+ +
+ PC +
+

+ PropChain +

+ + +
+
+
+ + {/* Main Content */} +
+ {/* Search Bar */} +
+

+ Discover Tokenized Real Estate +

+ setFilter('query', value)} + /> +
+ + {/* Layout: Sidebar + Results */} +
+ {/* Filter Sidebar */} + + + {/* Search Results */} + +
+
+
+ ); +} + +export default function PropertiesPage() { + return ( + +
+
+

Loading properties...

+
+
+ }> + +
+ ); +} diff --git a/src/components/FilterSidebar.tsx b/src/components/FilterSidebar.tsx new file mode 100644 index 00000000..b195e55d --- /dev/null +++ b/src/components/FilterSidebar.tsx @@ -0,0 +1,305 @@ +'use client'; + +import React, { useState } from 'react'; +import type { SearchFilters, PropertyType, BlockchainNetwork } from '@/types/property'; +import { PROPERTY_TYPE_LABELS, BLOCKCHAIN_LABELS } from '@/types/property'; + +interface FilterSidebarProps { + filters: SearchFilters; + onFilterChange: (key: K, value: SearchFilters[K]) => void; + onClearFilters: () => void; +} + +export const FilterSidebar: React.FC = ({ + filters, + onFilterChange, + onClearFilters, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const togglePropertyType = (type: PropertyType) => { + const current = filters.propertyTypes || []; + const updated = current.includes(type) + ? current.filter(t => t !== type) + : [...current, type]; + onFilterChange('propertyTypes', updated); + }; + + const toggleBlockchain = (chain: BlockchainNetwork) => { + const current = filters.blockchains || []; + const updated = current.includes(chain) + ? current.filter(c => c !== chain) + : [...current, chain]; + onFilterChange('blockchains', updated); + }; + + const hasActiveFilters = () => { + return ( + filters.priceRange[0] > 0 || + filters.priceRange[1] < 10000000 || + filters.propertyTypes.length > 0 || + filters.blockchains.length > 0 || + filters.roiMin > 0 || + filters.roiMax < 100 || + filters.location || + filters.bedrooms.length > 0 || + filters.bathrooms.length > 0 || + filters.squareFeetRange[0] > 0 || + filters.squareFeetRange[1] < 50000 + ); + }; + + return ( + <> + {/* Mobile Filter Button */} + + + {/* Overlay for mobile */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Sidebar */} +
+
+ {/* Header */} +
+

Filters

+
+ {hasActiveFilters() && ( + + )} + +
+
+ + {/* Price Range */} +
+ +
+
+ + onFilterChange('priceRange', [Number(e.target.value), filters.priceRange[1]])} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="$0" + /> +
+
+ + onFilterChange('priceRange', [filters.priceRange[0], Number(e.target.value)])} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="$10,000,000" + /> +
+
+
+ + {/* Property Type */} +
+ +
+ {(Object.keys(PROPERTY_TYPE_LABELS) as PropertyType[]).map((type) => ( + + ))} +
+
+ + {/* Blockchain */} +
+ +
+ {(Object.keys(BLOCKCHAIN_LABELS) as BlockchainNetwork[]).map((chain) => ( + + ))} +
+
+ + {/* ROI Range */} +
+ +
+
+ + onFilterChange('roiMin', Number(e.target.value))} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="0%" + min="0" + max="100" + /> +
+
+ + onFilterChange('roiMax', Number(e.target.value))} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="100%" + min="0" + max="100" + /> +
+
+
+ + {/* Bedrooms */} +
+ +
+ {[1, 2, 3, 4, 5].map((num) => ( + + ))} +
+
+ + {/* Bathrooms */} +
+ +
+ {[1, 2, 3, 4].map((num) => ( + + ))} +
+
+ + {/* Square Feet */} +
+ +
+
+ + onFilterChange('squareFeetRange', [Number(e.target.value), filters.squareFeetRange[1]])} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="0" + /> +
+
+ + onFilterChange('squareFeetRange', [filters.squareFeetRange[0], Number(e.target.value)])} + className="w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + placeholder="50,000" + /> +
+
+
+
+
+ + ); +}; diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx new file mode 100644 index 00000000..106e8c39 --- /dev/null +++ b/src/components/PropertyCard.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import type { Property } from '@/types/property'; +import { formatPrice, formatROI, getBlockchainColor, getPropertyTypeIcon } from '@/utils/searchUtils'; +import { BLOCKCHAIN_LABELS, PROPERTY_TYPE_LABELS } from '@/types/property'; + +interface PropertyCardProps { + property: Property; + viewMode?: 'grid' | 'list'; +} + +export const PropertyCard: React.FC = ({ + property, + viewMode = 'grid' +}) => { + const isListView = viewMode === 'list'; + + return ( + + {/* Image */} +
+ {property.name} + + {/* Badges */} +
+ {property.featured && ( + + ⭐ Featured + + )} + {property.verified && ( + + ✓ Verified + + )} +
+ + {/* ROI Badge */} +
+
+ {formatROI(property.metrics.roi)} ROI +
+
+ + {/* Blockchain Badge */} +
+
+
+ {BLOCKCHAIN_LABELS[property.blockchain]} +
+
+
+ + {/* Content */} +
+ {/* Property Type */} +
+ {getPropertyTypeIcon(property.propertyType)} + + {PROPERTY_TYPE_LABELS[property.propertyType]} + +
+ + {/* Title */} +

+ {property.name} +

+ + {/* Location */} +
+ + + + + + {property.location.city}, {property.location.state} + +
+ + {/* Description */} + {isListView && ( +

+ {property.description} +

+ )} + + {/* Details */} + {property.details.bedrooms && ( +
+ {property.details.bedrooms && ( +
+ + + + {property.details.bedrooms} bed +
+ )} + {property.details.bathrooms && ( +
+ + + + {property.details.bathrooms} bath +
+ )} +
+ + + + {property.details.squareFeet.toLocaleString()} sqft +
+
+ )} + + {/* Token Info */} +
+
+

Available Tokens

+

+ {property.tokenInfo.available.toLocaleString()} / {property.tokenInfo.totalSupply.toLocaleString()} +

+
+
+

Per Token

+

+ {formatPrice(property.price.perToken)} +

+
+
+ + {/* Price and CTA */} +
+
+

Total Value

+

+ {formatPrice(property.price.total)} +

+
+ +
+
+ + ); +}; diff --git a/src/components/PropertySearch.tsx b/src/components/PropertySearch.tsx new file mode 100644 index 00000000..7efd3a55 --- /dev/null +++ b/src/components/PropertySearch.tsx @@ -0,0 +1,214 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; +import { propertyService } from '@/lib/propertyService'; +import type { AutocompleteResult } from '@/types/property'; + +interface PropertySearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export const PropertySearch: React.FC = ({ + value, + onChange, + placeholder = 'Search properties, locations...', +}) => { + const [isFocused, setIsFocused] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + const debouncedValue = useDebounce(value, 300); + + // Fetch suggestions when debounced value changes + useEffect(() => { + if (debouncedValue && debouncedValue.length >= 2) { + fetchSuggestions(debouncedValue); + } else { + setSuggestions([]); + } + }, [debouncedValue]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsFocused(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const fetchSuggestions = async (query: string) => { + setIsLoading(true); + try { + const results = await propertyService.getAutocompleteSuggestions(query); + setSuggestions(results); + } catch (error) { + console.error('Failed to fetch suggestions:', error); + setSuggestions([]); + } finally { + setIsLoading(false); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + setSelectedIndex(-1); + }; + + const handleSuggestionClick = (suggestion: AutocompleteResult) => { + onChange(suggestion.value); + setSuggestions([]); + setIsFocused(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!suggestions.length) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < suggestions.length - 1 ? prev + 1 : prev + ); + break; + + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1)); + break; + + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + handleSuggestionClick(suggestions[selectedIndex]); + } + break; + + case 'Escape': + setIsFocused(false); + setSuggestions([]); + break; + } + }; + + const handleClear = () => { + onChange(''); + setSuggestions([]); + inputRef.current?.focus(); + }; + + const showDropdown = isFocused && (suggestions.length > 0 || isLoading); + + return ( +
+ {/* Search Input */} +
+
+ + + +
+ + setIsFocused(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full pl-12 pr-12 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500" + /> + + {value && ( + + )} +
+ + {/* Autocomplete Dropdown */} + {showDropdown && ( +
+ {isLoading ? ( +
+
+ Searching... +
+ ) : ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx new file mode 100644 index 00000000..e915429f --- /dev/null +++ b/src/components/SearchResults.tsx @@ -0,0 +1,209 @@ +'use client'; + +import React from 'react'; +import { PropertyCard } from './PropertyCard'; +import type { Property, ViewMode, SortOption } from '@/types/property'; +import { SORT_LABELS } from '@/types/property'; + +interface SearchResultsProps { + properties: Property[]; + totalResults: number; + isLoading: boolean; + error: string | null; + viewMode: 'grid' | 'list'; + sortBy: SortOption; + page: number; + totalPages: number; + onViewModeChange: (mode: 'grid' | 'list') => void; + onSortChange: (sort: SortOption) => void; + onPageChange: (page: number) => void; +} + +export const SearchResults: React.FC = ({ + properties, + totalResults, + isLoading, + error, + viewMode, + sortBy, + page, + totalPages, + onViewModeChange, + onSortChange, + onPageChange, +}) => { + if (error) { + return ( +
+ + + +

+ Oops! Something went wrong +

+

{error}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ {isLoading ? 'Searching...' : `${totalResults} Properties Found`} +

+ {page > 1 && ( +

+ Page {page} of {totalPages} +

+ )} +
+ +
+ {/* Sort Dropdown */} + + + {/* View Mode Toggle */} +
+ + +
+
+
+ + {/* Loading State */} + {isLoading && ( +
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Empty State */} + {!isLoading && properties.length === 0 && ( +
+ + + +

+ No properties found +

+

+ Try adjusting your filters or search criteria to find more properties. +

+
+ )} + + {/* Results Grid/List */} + {!isLoading && properties.length > 0 && ( + <> +
+ {properties.map((property) => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ {[...Array(Math.min(totalPages, 5))].map((_, i) => { + let pageNum; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (page <= 3) { + pageNum = i + 1; + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = page - 2 + i; + } + + return ( + + ); + })} +
+ + +
+ )} + + )} +
+ ); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..11163f1b --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +/** + * Custom hook for debouncing values + * Useful for search inputs to reduce API calls + */ + +export function useDebounce(value: T, delay: number = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // Set up the timeout + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Clean up the timeout if value changes before delay + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/usePropertySearch.ts b/src/hooks/usePropertySearch.ts new file mode 100644 index 00000000..11a8895d --- /dev/null +++ b/src/hooks/usePropertySearch.ts @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; +import { useSearchStore } from '@/store/searchStore'; +import { propertyService } from '@/lib/propertyService'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { filtersToUrlParams, urlParamsToFilters } from '@/utils/searchUtils'; + +/** + * Custom hook for property search functionality + * Combines search store, API calls, and URL synchronization + */ + +export function usePropertySearch() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isInitialized, setIsInitialized] = useState(false); + + const { + filters, + sortBy, + page, + resultsPerPage, + properties, + totalResults, + isLoading, + error, + setFilters, + setFilter, + clearFilters, + setSortBy, + setPage, + setProperties, + setLoading, + setError, + } = useSearchStore(); + + // Initialize from URL parameters on mount + useEffect(() => { + if (!isInitialized && searchParams) { + const { filters: urlFilters, sortBy: urlSortBy } = urlParamsToFilters(searchParams); + + if (Object.keys(urlFilters).length > 0) { + setFilters(urlFilters); + } + + if (urlSortBy && urlSortBy !== sortBy) { + setSortBy(urlSortBy); + } + + setIsInitialized(true); + } + }, [searchParams, isInitialized]); + + // Sync URL with state changes + useEffect(() => { + if (isInitialized) { + const params = filtersToUrlParams(filters, sortBy); + const currentParams = searchParams?.toString() || ''; + + if (params !== currentParams) { + router.push(`/properties?${params}`, { scroll: false }); + } + } + }, [filters, sortBy, isInitialized]); + + // Fetch properties when filters, sort, or page changes + useEffect(() => { + if (isInitialized) { + fetchProperties(); + } + }, [filters, sortBy, page, resultsPerPage, isInitialized]); + + const fetchProperties = async () => { + setLoading(true); + setError(null); + + try { + const result = await propertyService.searchProperties( + filters, + sortBy, + page, + resultsPerPage + ); + + setProperties(result.properties, result.total); + } catch (err: any) { + setError(err.message || 'Failed to fetch properties'); + setProperties([], 0); + } + }; + + const handleFilterChange = ( + key: K, + value: typeof filters[K] + ) => { + setFilter(key, value); + }; + + const handleClearFilters = () => { + clearFilters(); + }; + + const handleSortChange = (newSortBy: typeof sortBy) => { + setSortBy(newSortBy); + }; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const totalPages = Math.ceil(totalResults / resultsPerPage); + + return { + // State + filters, + sortBy, + page, + resultsPerPage, + properties, + totalResults, + totalPages, + isLoading, + error, + + // Actions + setFilter: handleFilterChange, + clearFilters: handleClearFilters, + setSortBy: handleSortChange, + setPage: handlePageChange, + refetch: fetchProperties, + }; +} diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts new file mode 100644 index 00000000..d64eb63a --- /dev/null +++ b/src/lib/mockData.ts @@ -0,0 +1,412 @@ +import type { Property, BlockchainNetwork } from '@/types/property'; + +/** + * Mock Property Data + * Sample tokenized real estate properties for development and testing + */ + +export const MOCK_PROPERTIES: Property[] = [ + { + id: '1', + name: 'Luxury Downtown Penthouse', + description: 'Stunning penthouse in the heart of Manhattan with panoramic city views. Premium finishes, smart home technology, and exclusive amenities.', + location: { + address: '432 Park Avenue', + city: 'New York', + state: 'NY', + country: 'USA', + zipCode: '10022', + coordinates: { lat: 40.7614, lng: -73.9776 }, + }, + price: { + total: 5000000, + perToken: 100, + currency: 'USD', + }, + propertyType: 'residential', + blockchain: 'ethereum', + tokenInfo: { + totalSupply: 50000, + available: 25000, + sold: 25000, + contractAddress: '0x1234567890abcdef1234567890abcdef12345678', + tokenSymbol: 'PENT432', + }, + metrics: { + roi: 8.5, + annualReturn: 425000, + transactionVolume: 2500000, + appreciationRate: 5.2, + }, + details: { + bedrooms: 3, + bathrooms: 3, + squareFeet: 3200, + yearBuilt: 2020, + parking: 2, + amenities: ['Gym', 'Pool', 'Concierge', 'Rooftop Terrace'], + }, + images: [ + 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800', + 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800', + ], + listedDate: '2024-01-15T00:00:00Z', + status: 'active', + featured: true, + verified: true, + }, + { + id: '2', + name: 'Modern Office Complex', + description: 'Class A office building in Silicon Valley tech hub. High-speed internet, modern infrastructure, and sustainable design.', + location: { + address: '1 Market Street', + city: 'San Francisco', + state: 'CA', + country: 'USA', + zipCode: '94105', + coordinates: { lat: 37.7749, lng: -122.4194 }, + }, + price: { + total: 12000000, + perToken: 200, + currency: 'USD', + }, + propertyType: 'commercial', + blockchain: 'polygon', + tokenInfo: { + totalSupply: 60000, + available: 40000, + sold: 20000, + contractAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + tokenSymbol: 'OFFC1M', + }, + metrics: { + roi: 12.3, + annualReturn: 1476000, + transactionVolume: 4000000, + appreciationRate: 7.8, + }, + details: { + squareFeet: 45000, + yearBuilt: 2019, + parking: 100, + amenities: ['Conference Rooms', 'Cafeteria', 'Fitness Center', 'EV Charging'], + }, + images: [ + 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800', + 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=800', + ], + listedDate: '2024-02-01T00:00:00Z', + status: 'active', + featured: true, + verified: true, + }, + { + id: '3', + name: 'Beachfront Villa Resort', + description: 'Exclusive beachfront property in Bali with private beach access. Perfect for vacation rentals and hospitality investment.', + location: { + address: 'Jalan Pantai Seminyak', + city: 'Seminyak', + state: 'Bali', + country: 'Indonesia', + zipCode: '80361', + coordinates: { lat: -8.6905, lng: 115.1683 }, + }, + price: { + total: 2500000, + perToken: 50, + currency: 'USD', + }, + propertyType: 'residential', + blockchain: 'bsc', + tokenInfo: { + totalSupply: 50000, + available: 35000, + sold: 15000, + contractAddress: '0x9876543210fedcba9876543210fedcba98765432', + tokenSymbol: 'VILL-BALI', + }, + metrics: { + roi: 15.7, + annualReturn: 392500, + transactionVolume: 750000, + appreciationRate: 9.3, + }, + details: { + bedrooms: 5, + bathrooms: 4, + squareFeet: 4500, + lotSize: 8000, + yearBuilt: 2021, + parking: 4, + amenities: ['Private Pool', 'Beach Access', 'Garden', 'Ocean View'], + }, + images: [ + 'https://images.unsplash.com/photo-1613490493576-7fde63acd811?w=800', + 'https://images.unsplash.com/photo-1582268611958-ebfd161ef9cf?w=800', + ], + listedDate: '2024-01-20T00:00:00Z', + status: 'active', + verified: true, + }, + { + id: '4', + name: 'Industrial Warehouse Hub', + description: 'Strategic logistics center near major highways. Ideal for e-commerce fulfillment and distribution operations.', + location: { + address: '500 Industrial Parkway', + city: 'Dallas', + state: 'TX', + country: 'USA', + zipCode: '75201', + coordinates: { lat: 32.7767, lng: -96.7970 }, + }, + price: { + total: 8000000, + perToken: 160, + currency: 'USD', + }, + propertyType: 'industrial', + blockchain: 'ethereum', + tokenInfo: { + totalSupply: 50000, + available: 30000, + sold: 20000, + contractAddress: '0x5555666677778888999900001111222233334444', + tokenSymbol: 'WARE-DLS', + }, + metrics: { + roi: 10.5, + annualReturn: 840000, + transactionVolume: 3200000, + appreciationRate: 6.1, + }, + details: { + squareFeet: 85000, + yearBuilt: 2018, + parking: 50, + amenities: ['Loading Docks', 'High Ceilings', 'Climate Control', 'Security System'], + }, + images: [ + 'https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800', + 'https://images.unsplash.com/photo-1553413077-190dd305871c?w=800', + ], + listedDate: '2024-01-10T00:00:00Z', + status: 'active', + verified: true, + }, + { + id: '5', + name: 'Mixed-Use Development', + description: 'Vibrant mixed-use property combining retail, office, and residential spaces in downtown Seattle.', + location: { + address: '1201 3rd Avenue', + city: 'Seattle', + state: 'WA', + country: 'USA', + zipCode: '98101', + coordinates: { lat: 47.6062, lng: -122.3321 }, + }, + price: { + total: 15000000, + perToken: 250, + currency: 'USD', + }, + propertyType: 'mixed-use', + blockchain: 'polygon', + tokenInfo: { + totalSupply: 60000, + available: 45000, + sold: 15000, + contractAddress: '0xaaaa1111bbbb2222cccc3333dddd4444eeee5555', + tokenSymbol: 'MIX-SEA', + }, + metrics: { + roi: 11.8, + annualReturn: 1770000, + transactionVolume: 3750000, + appreciationRate: 8.2, + }, + details: { + squareFeet: 120000, + yearBuilt: 2022, + parking: 200, + amenities: ['Retail Spaces', 'Office Floors', 'Residential Units', 'Public Plaza'], + }, + images: [ + 'https://images.unsplash.com/photo-1545324418-cc1a3fa10c00?w=800', + 'https://images.unsplash.com/photo-1577495508326-19a1b3cf65b7?w=800', + ], + listedDate: '2024-02-05T00:00:00Z', + status: 'active', + featured: true, + verified: true, + }, + { + id: '6', + name: 'Historic Brownstone', + description: 'Beautifully restored brownstone in Brooklyn with original architectural details and modern updates.', + location: { + address: '234 Prospect Park West', + city: 'Brooklyn', + state: 'NY', + country: 'USA', + zipCode: '11215', + coordinates: { lat: 40.6782, lng: -73.9442 }, + }, + price: { + total: 3200000, + perToken: 80, + currency: 'USD', + }, + propertyType: 'residential', + blockchain: 'ethereum', + tokenInfo: { + totalSupply: 40000, + available: 20000, + sold: 20000, + contractAddress: '0x1111aaaa2222bbbb3333cccc4444dddd5555eeee', + tokenSymbol: 'BRWN-BK', + }, + metrics: { + roi: 7.2, + annualReturn: 230400, + transactionVolume: 1600000, + appreciationRate: 4.5, + }, + details: { + bedrooms: 4, + bathrooms: 3, + squareFeet: 2800, + yearBuilt: 1890, + parking: 1, + amenities: ['Garden', 'Fireplace', 'Original Moldings', 'Updated Kitchen'], + }, + images: [ + 'https://images.unsplash.com/photo-1580587771525-78b9dba3b914?w=800', + 'https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800', + ], + listedDate: '2024-01-25T00:00:00Z', + status: 'active', + verified: true, + }, + { + id: '7', + name: 'Tech Campus', + description: 'Modern tech campus in Austin with collaborative workspaces and innovation labs.', + location: { + address: '500 W 2nd Street', + city: 'Austin', + state: 'TX', + country: 'USA', + zipCode: '78701', + coordinates: { lat: 30.2672, lng: -97.7431 }, + }, + price: { + total: 18000000, + perToken: 300, + currency: 'USD', + }, + propertyType: 'commercial', + blockchain: 'polygon', + tokenInfo: { + totalSupply: 60000, + available: 50000, + sold: 10000, + contractAddress: '0x6666777788889999aaaabbbbccccddddeeee0000', + tokenSymbol: 'TECH-ATX', + }, + metrics: { + roi: 13.5, + annualReturn: 2430000, + transactionVolume: 3000000, + appreciationRate: 10.1, + }, + details: { + squareFeet: 95000, + yearBuilt: 2023, + parking: 250, + amenities: ['Innovation Labs', 'Cafeteria', 'Gym', 'Outdoor Spaces'], + }, + images: [ + 'https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=800', + 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800', + ], + listedDate: '2024-02-10T00:00:00Z', + status: 'active', + featured: true, + verified: true, + }, + { + id: '8', + name: 'Mountain Chalet', + description: 'Luxury ski chalet in Aspen with ski-in/ski-out access and breathtaking mountain views.', + location: { + address: '100 Aspen Mountain Road', + city: 'Aspen', + state: 'CO', + country: 'USA', + zipCode: '81611', + coordinates: { lat: 39.1911, lng: -106.8175 }, + }, + price: { + total: 6500000, + perToken: 130, + currency: 'USD', + }, + propertyType: 'residential', + blockchain: 'bsc', + tokenInfo: { + totalSupply: 50000, + available: 38000, + sold: 12000, + contractAddress: '0x7777888899990000aaaa1111bbbb2222cccc3333', + tokenSymbol: 'CHAL-ASP', + }, + metrics: { + roi: 9.8, + annualReturn: 637000, + transactionVolume: 1560000, + appreciationRate: 6.7, + }, + details: { + bedrooms: 6, + bathrooms: 5, + squareFeet: 5500, + yearBuilt: 2020, + parking: 3, + amenities: ['Ski Access', 'Hot Tub', 'Wine Cellar', 'Home Theater'], + }, + images: [ + 'https://images.unsplash.com/photo-1542718610-a1d656d1884c?w=800', + 'https://images.unsplash.com/photo-1518780664697-55e3ad937233?w=800', + ], + listedDate: '2024-01-18T00:00:00Z', + status: 'active', + verified: true, + }, +]; + +// Helper function to get properties by blockchain +export function getPropertiesByBlockchain(blockchain: BlockchainNetwork): Property[] { + return MOCK_PROPERTIES.filter(p => p.blockchain === blockchain); +} + +// Helper function to get featured properties +export function getFeaturedProperties(): Property[] { + return MOCK_PROPERTIES.filter(p => p.featured); +} + +// Helper function to get unique locations +export function getUniqueLocations(): string[] { + const locations = MOCK_PROPERTIES.map(p => `${p.location.city}, ${p.location.state}`); + return [...new Set(locations)].sort(); +} + +// Helper function to get unique cities +export function getUniqueCities(): string[] { + const cities = MOCK_PROPERTIES.map(p => p.location.city); + return [...new Set(cities)].sort(); +} diff --git a/src/lib/propertyService.ts b/src/lib/propertyService.ts new file mode 100644 index 00000000..09ad0eb8 --- /dev/null +++ b/src/lib/propertyService.ts @@ -0,0 +1,277 @@ +import type { + Property, + SearchFilters, + PropertySearchResult, + SortOption, + AutocompleteResult, + SavedSearch, +} from '@/types/property'; +import { MOCK_PROPERTIES, getUniqueLocations } from './mockData'; + +/** + * Property Service + * Handles property search, filtering, and data operations + */ + +class PropertyService { + /** + * Search properties with filters and sorting + */ + async searchProperties( + filters: SearchFilters, + sortBy: SortOption = 'newest', + page: number = 1, + resultsPerPage: number = 12 + ): Promise { + // Simulate API delay + await this.delay(300); + + let results = [...MOCK_PROPERTIES]; + + // Apply filters + results = this.applyFilters(results, filters); + + // Apply sorting + results = this.applySorting(results, sortBy); + + // Calculate pagination + const total = results.length; + const totalPages = Math.ceil(total / resultsPerPage); + const startIndex = (page - 1) * resultsPerPage; + const endIndex = startIndex + resultsPerPage; + const paginatedResults = results.slice(startIndex, endIndex); + + return { + properties: paginatedResults, + total, + page, + totalPages, + }; + } + + /** + * Get a single property by ID + */ + async getPropertyById(id: string): Promise { + await this.delay(200); + return MOCK_PROPERTIES.find(p => p.id === id) || null; + } + + /** + * Get autocomplete suggestions + */ + async getAutocompleteSuggestions(query: string): Promise { + if (!query || query.length < 2) return []; + + await this.delay(150); + + const results: AutocompleteResult[] = []; + const lowerQuery = query.toLowerCase(); + + // Search property names + MOCK_PROPERTIES.forEach(property => { + if (property.name.toLowerCase().includes(lowerQuery)) { + results.push({ + type: 'property', + value: property.name, + label: property.name, + id: property.id, + }); + } + }); + + // Search locations + const locations = getUniqueLocations(); + locations.forEach(location => { + if (location.toLowerCase().includes(lowerQuery)) { + results.push({ + type: 'location', + value: location, + label: location, + }); + } + }); + + return results.slice(0, 8); // Limit to 8 suggestions + } + + /** + * Get saved searches for a user + */ + async getSavedSearches(userId: string): Promise { + await this.delay(200); + + // Get from localStorage + const saved = localStorage.getItem(`propchain-saved-searches-${userId}`); + return saved ? JSON.parse(saved) : []; + } + + /** + * Save a search for a user + */ + async saveSearch( + userId: string, + name: string, + filters: SearchFilters, + sortBy: SortOption + ): Promise { + await this.delay(200); + + const savedSearch: SavedSearch = { + id: this.generateId(), + name, + filters, + sortBy, + createdAt: new Date().toISOString(), + userId, + }; + + const existing = await this.getSavedSearches(userId); + const updated = [...existing, savedSearch]; + localStorage.setItem(`propchain-saved-searches-${userId}`, JSON.stringify(updated)); + + return savedSearch; + } + + /** + * Delete a saved search + */ + async deleteSavedSearch(userId: string, searchId: string): Promise { + await this.delay(200); + + const existing = await this.getSavedSearches(userId); + const updated = existing.filter(s => s.id !== searchId); + localStorage.setItem(`propchain-saved-searches-${userId}`, JSON.stringify(updated)); + } + + /** + * Apply filters to properties + */ + private applyFilters(properties: Property[], filters: SearchFilters): Property[] { + return properties.filter(property => { + // Query filter (search in name, description, location) + if (filters.query) { + const query = filters.query.toLowerCase(); + const searchableText = ` + ${property.name} + ${property.description} + ${property.location.city} + ${property.location.state} + ${property.location.address} + `.toLowerCase(); + + if (!searchableText.includes(query)) return false; + } + + // Price range filter + if (property.price.total < filters.priceRange[0] || + property.price.total > filters.priceRange[1]) { + return false; + } + + // Property type filter + if (filters.propertyTypes.length > 0 && + !filters.propertyTypes.includes(property.propertyType)) { + return false; + } + + // Blockchain filter + if (filters.blockchains.length > 0 && + !filters.blockchains.includes(property.blockchain)) { + return false; + } + + // ROI filter + if (property.metrics.roi < filters.roiMin || + property.metrics.roi > filters.roiMax) { + return false; + } + + // Location filter + if (filters.location) { + const locationQuery = filters.location.toLowerCase(); + const propertyLocation = `${property.location.city}, ${property.location.state}`.toLowerCase(); + if (!propertyLocation.includes(locationQuery)) return false; + } + + // Bedrooms filter + if (filters.bedrooms.length > 0 && property.details.bedrooms) { + if (!filters.bedrooms.includes(property.details.bedrooms)) return false; + } + + // Bathrooms filter + if (filters.bathrooms.length > 0 && property.details.bathrooms) { + if (!filters.bathrooms.includes(property.details.bathrooms)) return false; + } + + // Square feet filter + if (property.details.squareFeet < filters.squareFeetRange[0] || + property.details.squareFeet > filters.squareFeetRange[1]) { + return false; + } + + // Status filter + if (filters.status.length > 0 && !filters.status.includes(property.status)) { + return false; + } + + return true; + }); + } + + /** + * Apply sorting to properties + */ + private applySorting(properties: Property[], sortBy: SortOption): Property[] { + const sorted = [...properties]; + + switch (sortBy) { + case 'price-asc': + return sorted.sort((a, b) => a.price.total - b.price.total); + + case 'price-desc': + return sorted.sort((a, b) => b.price.total - a.price.total); + + case 'roi-desc': + return sorted.sort((a, b) => b.metrics.roi - a.metrics.roi); + + case 'roi-asc': + return sorted.sort((a, b) => a.metrics.roi - b.metrics.roi); + + case 'newest': + return sorted.sort((a, b) => + new Date(b.listedDate).getTime() - new Date(a.listedDate).getTime() + ); + + case 'oldest': + return sorted.sort((a, b) => + new Date(a.listedDate).getTime() - new Date(b.listedDate).getTime() + ); + + case 'volume-desc': + return sorted.sort((a, b) => + b.metrics.transactionVolume - a.metrics.transactionVolume + ); + + default: + return sorted; + } + } + + /** + * Simulate API delay + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +} + +// Export singleton instance +export const propertyService = new PropertyService(); diff --git a/src/store/savedSearchStore.ts b/src/store/savedSearchStore.ts new file mode 100644 index 00000000..33643c50 --- /dev/null +++ b/src/store/savedSearchStore.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { SavedSearch } from '@/types/property'; +import { propertyService } from '@/lib/propertyService'; + +/** + * Saved Searches Store + * Manages user's saved search queries + */ + +interface SavedSearchState { + searches: SavedSearch[]; + isLoading: boolean; + error: string | null; + + // Actions + loadSearches: (userId: string) => Promise; + addSearch: (search: SavedSearch) => void; + removeSearch: (searchId: string, userId: string) => Promise; + clearSearches: () => void; +} + +export const useSavedSearchStore = create()( + persist( + (set, get) => ({ + searches: [], + isLoading: false, + error: null, + + loadSearches: async (userId: string) => { + set({ isLoading: true, error: null }); + try { + const searches = await propertyService.getSavedSearches(userId); + set({ searches, isLoading: false }); + } catch (error: any) { + set({ error: error.message, isLoading: false }); + } + }, + + addSearch: (search: SavedSearch) => { + set((state) => ({ + searches: [...state.searches, search], + })); + }, + + removeSearch: async (searchId: string, userId: string) => { + set({ isLoading: true, error: null }); + try { + await propertyService.deleteSavedSearch(userId, searchId); + set((state) => ({ + searches: state.searches.filter(s => s.id !== searchId), + isLoading: false, + })); + } catch (error: any) { + set({ error: error.message, isLoading: false }); + } + }, + + clearSearches: () => { + set({ searches: [] }); + }, + }), + { + name: 'propchain-saved-searches', + partialize: (state) => ({ + searches: state.searches, + }), + } + ) +); diff --git a/src/store/searchStore.ts b/src/store/searchStore.ts new file mode 100644 index 00000000..aa5b966b --- /dev/null +++ b/src/store/searchStore.ts @@ -0,0 +1,145 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { + SearchFilters, + SortOption, + ViewMode, + Property, + DEFAULT_FILTERS, +} from '@/types/property'; + +/** + * Search Store + * Global state management for property search and filtering + */ + +interface SearchState { + // Filters + filters: SearchFilters; + sortBy: SortOption; + viewMode: ViewMode; + + // Pagination + page: number; + resultsPerPage: number; + totalResults: number; + + // Loading states + isLoading: boolean; + error: string | null; + + // Results + properties: Property[]; + + // Actions + setFilters: (filters: Partial) => void; + setFilter: (key: K, value: SearchFilters[K]) => void; + clearFilters: () => void; + setSortBy: (sortBy: SortOption) => void; + setViewMode: (viewMode: ViewMode) => void; + setPage: (page: number) => void; + setResultsPerPage: (count: number) => void; + setProperties: (properties: Property[], total: number) => void; + setLoading: (isLoading: boolean) => void; + setError: (error: string | null) => void; + reset: () => void; +} + +const DEFAULT_STATE = { + filters: { + query: '', + priceRange: [0, 10000000] as [number, number], + propertyTypes: [], + blockchains: [], + roiMin: 0, + roiMax: 100, + location: '', + bedrooms: [], + bathrooms: [], + squareFeetRange: [0, 50000] as [number, number], + status: ['active'] as any[], + }, + sortBy: 'newest' as SortOption, + viewMode: 'grid' as ViewMode, + page: 1, + resultsPerPage: 12, + totalResults: 0, + isLoading: false, + error: null, + properties: [], +}; + +export const useSearchStore = create()( + persist( + (set, get) => ({ + ...DEFAULT_STATE, + + setFilters: (newFilters) => { + set((state) => ({ + filters: { ...state.filters, ...newFilters }, + page: 1, // Reset to first page when filters change + })); + }, + + setFilter: (key, value) => { + set((state) => ({ + filters: { ...state.filters, [key]: value }, + page: 1, + })); + }, + + clearFilters: () => { + set({ + filters: DEFAULT_STATE.filters, + page: 1, + }); + }, + + setSortBy: (sortBy) => { + set({ sortBy, page: 1 }); + }, + + setViewMode: (viewMode) => { + set({ viewMode }); + }, + + setPage: (page) => { + set({ page }); + }, + + setResultsPerPage: (resultsPerPage) => { + set({ resultsPerPage, page: 1 }); + }, + + setProperties: (properties, total) => { + set({ + properties, + totalResults: total, + isLoading: false, + error: null, + }); + }, + + setLoading: (isLoading) => { + set({ isLoading }); + }, + + setError: (error) => { + set({ error, isLoading: false }); + }, + + reset: () => { + set(DEFAULT_STATE); + }, + }), + { + name: 'propchain-search', + partialize: (state) => ({ + filters: state.filters, + sortBy: state.sortBy, + viewMode: state.viewMode, + resultsPerPage: state.resultsPerPage, + }), + } + ) +); diff --git a/src/types/property.ts b/src/types/property.ts new file mode 100644 index 00000000..2fd3528c --- /dev/null +++ b/src/types/property.ts @@ -0,0 +1,169 @@ +/** + * Property Types and Interfaces + * Core data structures for the property search and filtering system + */ + +export type PropertyType = 'residential' | 'commercial' | 'industrial' | 'mixed-use'; +export type PropertyStatus = 'active' | 'sold' | 'pending'; +export type BlockchainNetwork = 'ethereum' | 'polygon' | 'bsc'; + +export interface PropertyLocation { + address: string; + city: string; + state: string; + country: string; + zipCode: string; + coordinates: { + lat: number; + lng: number; + }; +} + +export interface PropertyPrice { + total: number; + perToken: number; + currency: string; +} + +export interface TokenInfo { + totalSupply: number; + available: number; + sold: number; + contractAddress: string; + tokenSymbol: string; +} + +export interface PropertyMetrics { + roi: number; // Annual ROI percentage + annualReturn: number; // Expected annual return in currency + transactionVolume: number; // Total transaction volume + appreciationRate: number; // Historical appreciation rate +} + +export interface PropertyDetails { + bedrooms?: number; + bathrooms?: number; + squareFeet: number; + lotSize?: number; + yearBuilt: number; + parking?: number; + amenities: string[]; +} + +export interface Property { + id: string; + name: string; + description: string; + location: PropertyLocation; + price: PropertyPrice; + propertyType: PropertyType; + blockchain: BlockchainNetwork; + tokenInfo: TokenInfo; + metrics: PropertyMetrics; + details: PropertyDetails; + images: string[]; + listedDate: string; + status: PropertyStatus; + featured?: boolean; + verified?: boolean; +} + +export interface SearchFilters { + query: string; + priceRange: [number, number]; + propertyTypes: PropertyType[]; + blockchains: BlockchainNetwork[]; + roiMin: number; + roiMax: number; + location: string; + bedrooms: number[]; + bathrooms: number[]; + squareFeetRange: [number, number]; + status: PropertyStatus[]; +} + +export type SortOption = + | 'price-asc' + | 'price-desc' + | 'roi-desc' + | 'roi-asc' + | 'newest' + | 'oldest' + | 'volume-desc'; + +export type ViewMode = 'grid' | 'list' | 'map'; + +export interface SearchState { + filters: SearchFilters; + sortBy: SortOption; + viewMode: ViewMode; + page: number; + resultsPerPage: number; + totalResults: number; + isLoading: boolean; + error: string | null; +} + +export interface SavedSearch { + id: string; + name: string; + filters: SearchFilters; + sortBy: SortOption; + createdAt: string; + userId: string; // Wallet address +} + +export interface PropertySearchResult { + properties: Property[]; + total: number; + page: number; + totalPages: number; +} + +export interface AutocompleteResult { + type: 'property' | 'location'; + value: string; + label: string; + id?: string; +} + +// Default filter values +export const DEFAULT_FILTERS: SearchFilters = { + query: '', + priceRange: [0, 10000000], + propertyTypes: [], + blockchains: [], + roiMin: 0, + roiMax: 100, + location: '', + bedrooms: [], + bathrooms: [], + squareFeetRange: [0, 50000], + status: ['active'], +}; + +// Property type labels +export const PROPERTY_TYPE_LABELS: Record = { + residential: 'Residential', + commercial: 'Commercial', + industrial: 'Industrial', + 'mixed-use': 'Mixed Use', +}; + +// Blockchain labels +export const BLOCKCHAIN_LABELS: Record = { + ethereum: 'Ethereum', + polygon: 'Polygon', + bsc: 'Binance Smart Chain', +}; + +// Sort option labels +export const SORT_LABELS: Record = { + 'price-asc': 'Price: Low to High', + 'price-desc': 'Price: High to Low', + 'roi-desc': 'ROI: High to Low', + 'roi-asc': 'ROI: Low to High', + 'newest': 'Newest First', + 'oldest': 'Oldest First', + 'volume-desc': 'Transaction Volume', +}; diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts new file mode 100644 index 00000000..328631f9 --- /dev/null +++ b/src/utils/searchUtils.ts @@ -0,0 +1,211 @@ +import type { SearchFilters, SortOption } from '@/types/property'; + +/** + * Search Utility Functions + * Helper functions for search and filter operations + */ + +/** + * Convert search filters to URL parameters + */ +export function filtersToUrlParams(filters: SearchFilters, sortBy: SortOption): string { + const params = new URLSearchParams(); + + if (filters.query) params.set('q', filters.query); + if (filters.priceRange[0] > 0) params.set('minPrice', filters.priceRange[0].toString()); + if (filters.priceRange[1] < 10000000) params.set('maxPrice', filters.priceRange[1].toString()); + if (filters.propertyTypes.length > 0) params.set('types', filters.propertyTypes.join(',')); + if (filters.blockchains.length > 0) params.set('chains', filters.blockchains.join(',')); + if (filters.roiMin > 0) params.set('minRoi', filters.roiMin.toString()); + if (filters.roiMax < 100) params.set('maxRoi', filters.roiMax.toString()); + if (filters.location) params.set('location', filters.location); + if (filters.bedrooms.length > 0) params.set('bedrooms', filters.bedrooms.join(',')); + if (filters.bathrooms.length > 0) params.set('bathrooms', filters.bathrooms.join(',')); + if (filters.squareFeetRange[0] > 0) params.set('minSqft', filters.squareFeetRange[0].toString()); + if (filters.squareFeetRange[1] < 50000) params.set('maxSqft', filters.squareFeetRange[1].toString()); + if (sortBy !== 'newest') params.set('sort', sortBy); + + return params.toString(); +} + +/** + * Parse URL parameters to search filters + */ +export function urlParamsToFilters(searchParams: URLSearchParams): { + filters: Partial; + sortBy: SortOption; +} { + const filters: Partial = {}; + let sortBy: SortOption = 'newest'; + + const query = searchParams.get('q'); + if (query) filters.query = query; + + const minPrice = searchParams.get('minPrice'); + const maxPrice = searchParams.get('maxPrice'); + if (minPrice || maxPrice) { + filters.priceRange = [ + minPrice ? parseInt(minPrice) : 0, + maxPrice ? parseInt(maxPrice) : 10000000, + ]; + } + + const types = searchParams.get('types'); + if (types) filters.propertyTypes = types.split(',') as any[]; + + const chains = searchParams.get('chains'); + if (chains) filters.blockchains = chains.split(',') as any[]; + + const minRoi = searchParams.get('minRoi'); + const maxRoi = searchParams.get('maxRoi'); + if (minRoi) filters.roiMin = parseFloat(minRoi); + if (maxRoi) filters.roiMax = parseFloat(maxRoi); + + const location = searchParams.get('location'); + if (location) filters.location = location; + + const bedrooms = searchParams.get('bedrooms'); + if (bedrooms) filters.bedrooms = bedrooms.split(',').map(Number); + + const bathrooms = searchParams.get('bathrooms'); + if (bathrooms) filters.bathrooms = bathrooms.split(',').map(Number); + + const minSqft = searchParams.get('minSqft'); + const maxSqft = searchParams.get('maxSqft'); + if (minSqft || maxSqft) { + filters.squareFeetRange = [ + minSqft ? parseInt(minSqft) : 0, + maxSqft ? parseInt(maxSqft) : 50000, + ]; + } + + const sort = searchParams.get('sort'); + if (sort) sortBy = sort as SortOption; + + return { filters, sortBy }; +} + +/** + * Format price for display + */ +export function formatPrice(price: number, currency: string = 'USD'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price); +} + +/** + * Format number with commas + */ +export function formatNumber(num: number): string { + return new Intl.NumberFormat('en-US').format(num); +} + +/** + * Format ROI percentage + */ +export function formatROI(roi: number): string { + return `${roi.toFixed(1)}%`; +} + +/** + * Format date for display + */ +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); +} + +/** + * Calculate time ago from date + */ +export function timeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + const intervals = { + year: 31536000, + month: 2592000, + week: 604800, + day: 86400, + hour: 3600, + minute: 60, + }; + + for (const [unit, secondsInUnit] of Object.entries(intervals)) { + const interval = Math.floor(seconds / secondsInUnit); + if (interval >= 1) { + return `${interval} ${unit}${interval > 1 ? 's' : ''} ago`; + } + } + + return 'Just now'; +} + +/** + * Truncate text with ellipsis + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + '...'; +} + +/** + * Get blockchain color + */ +export function getBlockchainColor(blockchain: string): string { + const colors: Record = { + ethereum: '#627EEA', + polygon: '#8247E5', + bsc: '#F3BA2F', + }; + return colors[blockchain] || '#666666'; +} + +/** + * Get property type icon + */ +export function getPropertyTypeIcon(type: string): string { + const icons: Record = { + residential: '🏠', + commercial: '🏢', + industrial: '🏭', + 'mixed-use': '🏗️', + }; + return icons[type] || '🏘️'; +} + +/** + * Validate search query + */ +export function isValidSearchQuery(query: string): boolean { + return query.trim().length >= 2; +} + +/** + * Debounce function + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return function executedFunction(...args: Parameters) { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} diff --git a/tsc_output.txt b/tsc_output.txt new file mode 100644 index 00000000..c158c354 Binary files /dev/null and b/tsc_output.txt differ